From 53a3dc52bc448e112b73128fe8dda3aeacf42a13 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 16 May 2025 12:00:39 +1000 Subject: [PATCH 001/210] feat(ui): viewer is a modal (wip) --- .../src/app/components/GlobalHookIsolator.tsx | 8 +++ .../app/components/GlobalModalIsolator.tsx | 2 + .../components/CanvasDropArea.tsx | 6 -- .../components/CanvasMainPanelContent.tsx | 2 - .../components/CanvasRightPanel.tsx | 17 +---- .../NewSessionConfirmationAlertDialog.tsx | 9 +-- .../components/Tool/ToolBboxButton.tsx | 6 +- .../components/Tool/ToolBrushButton.tsx | 6 +- .../components/Tool/ToolBrushWidth.tsx | 10 ++- .../components/Tool/ToolColorPickerButton.tsx | 6 +- .../components/Tool/ToolEraserButton.tsx | 6 +- .../components/Tool/ToolEraserWidth.tsx | 10 ++- .../components/Tool/ToolFillColorPicker.tsx | 6 +- .../components/Tool/ToolMoveButton.tsx | 6 +- .../components/Tool/ToolRectButton.tsx | 6 +- .../components/Tool/ToolViewButton.tsx | 6 +- .../Toolbar/CanvasToolbarResetViewButton.tsx | 26 ++++---- .../hooks/useCanvasDeleteLayerHotkey.ts | 7 +- .../hooks/useCanvasEntityQuickSwitchHotkey.ts | 5 +- .../hooks/useCanvasResetLayerHotkey.ts | 6 +- .../hooks/useCanvasUndoRedoHotkeys.tsx | 10 ++- .../controlLayers/hooks/useEntityFilter.ts | 5 +- .../hooks/useEntitySegmentAnything.ts | 5 +- .../controlLayers/hooks/useEntityTransform.ts | 8 +-- .../web/src/features/dnd/DndImage.tsx | 4 ++ .../components/ImageViewer/ImageViewer.tsx | 65 ++++++++++++------- .../components/ImageViewer/useImageViewer.ts | 3 +- .../FloatingParametersPanelButtons.tsx | 4 +- 28 files changed, 114 insertions(+), 146 deletions(-) diff --git a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx index 5dfcdcab5b..4be48fcd2c 100644 --- a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx @@ -10,9 +10,11 @@ import type { PartialAppConfig } from 'app/types/invokeai'; import { useFocusRegionWatcher } from 'common/hooks/focus'; import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys'; import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher'; +import { toggleImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher'; import { useReadinessWatcher } from 'features/queue/store/readiness'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { configChanged } from 'features/system/store/configSlice'; import { selectLanguage } from 'features/system/store/systemSelectors'; import i18n from 'i18n'; @@ -61,6 +63,12 @@ export const GlobalHookIsolator = memo( useWorkflowBuilderWatcher(); useDynamicPromptsWatcher(); + useRegisteredHotkeys({ + id: 'toggleViewer', + category: 'viewer', + callback: toggleImageViewer, + }); + return null; } ); diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index da982d105f..ae83f0c5c2 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -11,6 +11,7 @@ import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { ImageViewerModal } from 'features/gallery/components/ImageViewer/ImageViewer'; import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal'; import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal'; import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; @@ -58,6 +59,7 @@ export const GlobalModalIsolator = memo(() => { + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index e988ecce68..adf21bd318 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -2,7 +2,6 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { newCanvasEntityFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -19,13 +18,8 @@ const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDn export const CanvasDropArea = memo(() => { const { t } = useTranslation(); - const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusy(); - if (imageViewer.isOpen) { - return null; - } - return ( <> { - ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index ac0c669005..c1bca93a26 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -11,7 +11,6 @@ import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import type { DndTargetState } from 'features/dnd/types'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -32,11 +31,8 @@ export const CanvasRightPanel = memo(() => { }, [activeTab]); const onClickViewerToggleButton = useCallback(() => { - if (activeTab !== 'gallery') { - dispatch(activeTabCanvasRightPanelChanged('gallery')); - } - imageViewer.toggle(); - }, [imageViewer, activeTab, dispatch]); + imageViewer.open(); + }, [imageViewer]); const onChangeTab = useCallback( (index: number) => { @@ -49,20 +45,13 @@ export const CanvasRightPanel = memo(() => { [dispatch] ); - useRegisteredHotkeys({ - id: 'toggleViewer', - category: 'viewer', - callback: imageViewer.toggle, - dependencies: [imageViewer], - }); - return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx index b365abb807..f57818964c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx @@ -3,7 +3,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { buildUseBoolean } from 'common/hooks/useBoolean'; import { newCanvasSessionRequested, newGallerySessionRequested } from 'features/controlLayers/store/actions'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { selectSystemShouldConfirmOnNewSession, shouldConfirmOnNewSessionToggled, @@ -17,15 +16,13 @@ const [useNewCanvasSessionDialog] = buildUseBoolean(false); export const useNewGallerySession = () => { const dispatch = useAppDispatch(); - const imageViewer = useImageViewer(); const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); const newSessionDialog = useNewGallerySessionDialog(); const newGallerySessionImmediate = useCallback(() => { dispatch(newGallerySessionRequested()); - imageViewer.open(); dispatch(activeTabCanvasRightPanelChanged('gallery')); - }, [dispatch, imageViewer]); + }, [dispatch]); const newGallerySessionWithDialog = useCallback(() => { if (shouldConfirmOnNewSession) { @@ -40,15 +37,13 @@ export const useNewGallerySession = () => { export const useNewCanvasSession = () => { const dispatch = useAppDispatch(); - const imageViewer = useImageViewer(); const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); const newSessionDialog = useNewCanvasSessionDialog(); const newCanvasSessionImmediate = useCallback(() => { dispatch(newCanvasSessionRequested()); - imageViewer.close(); dispatch(activeTabCanvasRightPanelChanged('layers')); - }, [dispatch, imageViewer]); + }, [dispatch]); const newCanvasSessionWithDialog = useCallback(() => { if (shouldConfirmOnNewSession) { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx index 4796092faf..30f5eb0c38 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx @@ -1,6 +1,5 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,14 +9,13 @@ export const ToolBboxButton = memo(() => { const { t } = useTranslation(); const selectBbox = useSelectTool('bbox'); const isSelected = useToolIsSelected('bbox'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'selectBboxTool', category: 'canvas', callback: selectBbox, - options: { enabled: !isSelected && !imageViewer.isOpen }, - dependencies: [selectBbox, isSelected, imageViewer.isOpen], + options: { enabled: !isSelected }, + dependencies: [selectBbox, isSelected], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx index d9afdb18f5..1f3f2a0044 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushButton.tsx @@ -1,6 +1,5 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,14 +9,13 @@ export const ToolBrushButton = memo(() => { const { t } = useTranslation(); const isSelected = useToolIsSelected('brush'); const selectBrush = useSelectTool('brush'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'selectBrushTool', category: 'canvas', callback: selectBrush, - options: { enabled: !isSelected && !imageViewer.isOpen }, - dependencies: [isSelected, selectBrush, imageViewer.isOpen], + options: { enabled: !isSelected }, + dependencies: [isSelected, selectBrush], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx index a8dcb12c71..6d14493d50 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx @@ -16,7 +16,6 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { selectCanvasSettingsSlice, settingsBrushWidthChanged } from 'features/controlLayers/store/canvasSettingsSlice'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { clamp } from 'lodash-es'; import type { KeyboardEvent } from 'react'; @@ -69,7 +68,6 @@ const sliderDefaultValue = mapRawValueToSliderValue(50); export const ToolBrushWidth = memo(() => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const imageViewer = useImageViewer(); const isSelected = useToolIsSelected('brush'); const width = useAppSelector(selectBrushWidth); const [localValue, setLocalValue] = useState(width); @@ -133,15 +131,15 @@ export const ToolBrushWidth = memo(() => { id: 'decrementToolWidth', category: 'canvas', callback: decrement, - options: { enabled: isSelected && !imageViewer.isOpen }, - dependencies: [decrement, isSelected, imageViewer.isOpen], + options: { enabled: isSelected }, + dependencies: [decrement, isSelected], }); useRegisteredHotkeys({ id: 'incrementToolWidth', category: 'canvas', callback: increment, - options: { enabled: isSelected && !imageViewer.isOpen }, - dependencies: [increment, isSelected, imageViewer.isOpen], + options: { enabled: isSelected }, + dependencies: [increment, isSelected], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx index 1f48b2ee72..a8c590163e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx @@ -1,6 +1,5 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,14 +9,13 @@ export const ToolColorPickerButton = memo(() => { const { t } = useTranslation(); const isSelected = useToolIsSelected('colorPicker'); const selectColorPicker = useSelectTool('colorPicker'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'selectColorPickerTool', category: 'canvas', callback: selectColorPicker, - options: { enabled: !isSelected && !imageViewer.isOpen }, - dependencies: [selectColorPicker, isSelected, imageViewer.isOpen], + options: { enabled: !isSelected }, + dependencies: [selectColorPicker, isSelected], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx index 8257523367..f9de6cc4aa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserButton.tsx @@ -1,6 +1,5 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,14 +9,13 @@ export const ToolEraserButton = memo(() => { const { t } = useTranslation(); const isSelected = useToolIsSelected('eraser'); const selectEraser = useSelectTool('eraser'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'selectEraserTool', category: 'canvas', callback: selectEraser, - options: { enabled: !isSelected && !imageViewer.isOpen }, - dependencies: [isSelected, selectEraser, imageViewer.isOpen], + options: { enabled: !isSelected }, + dependencies: [isSelected, selectEraser], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserWidth.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserWidth.tsx index 1bcdd69297..a0821c8776 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserWidth.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolEraserWidth.tsx @@ -19,7 +19,6 @@ import { selectCanvasSettingsSlice, settingsEraserWidthChanged, } from 'features/controlLayers/store/canvasSettingsSlice'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { clamp } from 'lodash-es'; import type { KeyboardEvent } from 'react'; @@ -72,7 +71,6 @@ const sliderDefaultValue = mapRawValueToSliderValue(50); export const ToolEraserWidth = memo(() => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const imageViewer = useImageViewer(); const isSelected = useToolIsSelected('eraser'); const width = useAppSelector(selectEraserWidth); const [localValue, setLocalValue] = useState(width); @@ -136,15 +134,15 @@ export const ToolEraserWidth = memo(() => { id: 'decrementToolWidth', category: 'canvas', callback: decrement, - options: { enabled: isSelected && !imageViewer.isOpen }, - dependencies: [decrement, isSelected, imageViewer.isOpen], + options: { enabled: isSelected }, + dependencies: [decrement, isSelected], }); useRegisteredHotkeys({ id: 'incrementToolWidth', category: 'canvas', callback: increment, - options: { enabled: isSelected && !imageViewer.isOpen }, - dependencies: [increment, isSelected, imageViewer.isOpen], + options: { enabled: isSelected }, + dependencies: [increment, isSelected], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx index 7d1b4bdd0e..8b3de75e72 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx @@ -14,7 +14,6 @@ import RgbaColorPicker from 'common/components/ColorPicker/RgbaColorPicker'; import { rgbaColorToString } from 'common/util/colorCodeTransformers'; import { selectCanvasSettingsSlice, settingsColorChanged } from 'features/controlLayers/store/canvasSettingsSlice'; import type { RgbaColor } from 'features/controlLayers/store/types'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -31,14 +30,13 @@ export const ToolColorPicker = memo(() => { }, [dispatch] ); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'setFillToWhite', category: 'canvas', callback: () => dispatch(settingsColorChanged({ r: 255, g: 255, b: 255, a: 1 })), - options: { preventDefault: true, enabled: !imageViewer.isOpen }, - dependencies: [dispatch, imageViewer.isOpen], + options: { preventDefault: true }, + dependencies: [dispatch], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx index 63cbbbce8f..cd842e64b6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx @@ -1,6 +1,5 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,14 +9,13 @@ export const ToolMoveButton = memo(() => { const { t } = useTranslation(); const isSelected = useToolIsSelected('move'); const selectMove = useSelectTool('move'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'selectMoveTool', category: 'canvas', callback: selectMove, - options: { enabled: !isSelected && !imageViewer.isOpen }, - dependencies: [isSelected, selectMove, imageViewer.isOpen], + options: { enabled: !isSelected }, + dependencies: [isSelected, selectMove], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx index 6e9251c1ef..9302939088 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx @@ -1,6 +1,5 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,14 +9,13 @@ export const ToolRectButton = memo(() => { const { t } = useTranslation(); const isSelected = useToolIsSelected('rect'); const selectRect = useSelectTool('rect'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'selectRectTool', category: 'canvas', callback: selectRect, - options: { enabled: !isSelected && !imageViewer.isOpen }, - dependencies: [isSelected, selectRect, imageViewer.isOpen], + options: { enabled: !isSelected }, + dependencies: [isSelected, selectRect], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx index f58e66b91c..f16c055fcf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolViewButton.tsx @@ -1,6 +1,5 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,14 +9,13 @@ export const ToolViewButton = memo(() => { const { t } = useTranslation(); const isSelected = useToolIsSelected('view'); const selectView = useSelectTool('view'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'selectViewTool', category: 'canvas', callback: selectView, - options: { enabled: !isSelected && !imageViewer.isOpen }, - dependencies: [selectView, isSelected, imageViewer.isOpen], + options: { enabled: !isSelected }, + dependencies: [selectView, isSelected], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx index 88aae8a2ba..547575cff2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx @@ -1,7 +1,6 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,49 +10,48 @@ export const CanvasToolbarResetViewButton = memo(() => { const { t } = useTranslation(); const canvasManager = useCanvasManager(); const isCanvasFocused = useIsRegionFocused('canvas'); - const imageViewer = useImageViewer(); useRegisteredHotkeys({ id: 'fitLayersToCanvas', category: 'canvas', callback: canvasManager.stage.fitLayersToStage, - options: { enabled: isCanvasFocused && !imageViewer.isOpen, preventDefault: true }, - dependencies: [isCanvasFocused, imageViewer.isOpen], + options: { enabled: isCanvasFocused, preventDefault: true }, + dependencies: [isCanvasFocused], }); useRegisteredHotkeys({ id: 'fitBboxToCanvas', category: 'canvas', callback: canvasManager.stage.fitBboxToStage, - options: { enabled: isCanvasFocused && !imageViewer.isOpen, preventDefault: true }, - dependencies: [isCanvasFocused, imageViewer.isOpen], + options: { enabled: isCanvasFocused, preventDefault: true }, + dependencies: [isCanvasFocused], }); useRegisteredHotkeys({ id: 'setZoomTo100Percent', category: 'canvas', callback: () => canvasManager.stage.setScale(1), - options: { enabled: isCanvasFocused && !imageViewer.isOpen, preventDefault: true }, - dependencies: [isCanvasFocused, imageViewer.isOpen], + options: { enabled: isCanvasFocused, preventDefault: true }, + dependencies: [isCanvasFocused], }); useRegisteredHotkeys({ id: 'setZoomTo200Percent', category: 'canvas', callback: () => canvasManager.stage.setScale(2), - options: { enabled: isCanvasFocused && !imageViewer.isOpen, preventDefault: true }, - dependencies: [isCanvasFocused, imageViewer.isOpen], + options: { enabled: isCanvasFocused, preventDefault: true }, + dependencies: [isCanvasFocused], }); useRegisteredHotkeys({ id: 'setZoomTo400Percent', category: 'canvas', callback: () => canvasManager.stage.setScale(4), - options: { enabled: isCanvasFocused && !imageViewer.isOpen, preventDefault: true }, - dependencies: [isCanvasFocused, imageViewer.isOpen], + options: { enabled: isCanvasFocused, preventDefault: true }, + dependencies: [isCanvasFocused], }); useRegisteredHotkeys({ id: 'setZoomTo800Percent', category: 'canvas', callback: () => canvasManager.stage.setScale(8), - options: { enabled: isCanvasFocused && !imageViewer.isOpen, preventDefault: true }, - dependencies: [isCanvasFocused, imageViewer.isOpen], + options: { enabled: isCanvasFocused, preventDefault: true }, + dependencies: [isCanvasFocused], }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasDeleteLayerHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasDeleteLayerHotkey.ts index 98d9222bd7..11ac1b6897 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasDeleteLayerHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasDeleteLayerHotkey.ts @@ -3,7 +3,6 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { entityDeleted } from 'features/controlLayers/store/canvasSlice'; import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; import { useCallback, useMemo } from 'react'; @@ -16,8 +15,6 @@ export function useCanvasDeleteLayerHotkey() { const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel); const appTab = useAppSelector(selectActiveTab); - const imageViewer = useImageViewer(); - const deleteSelectedLayer = useCallback(() => { if (selectedEntityIdentifier === null) { return; @@ -34,7 +31,7 @@ export function useCanvasDeleteLayerHotkey() { id: 'deleteSelected', category: 'canvas', callback: deleteSelectedLayer, - options: { enabled: isDeleteEnabled && !imageViewer.isOpen }, - dependencies: [isDeleteEnabled, deleteSelectedLayer, imageViewer.isOpen], + options: { enabled: isDeleteEnabled }, + dependencies: [isDeleteEnabled, deleteSelectedLayer], }); } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey.ts index 10b51c585f..f8ee66c289 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey.ts @@ -5,7 +5,6 @@ import { selectSelectedEntityIdentifier, } from 'features/controlLayers/store/selectors'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useEffect, useState } from 'react'; @@ -15,7 +14,6 @@ export const useCanvasEntityQuickSwitchHotkey = () => { const [current, setCurrent] = useState(null); const selected = useAppSelector(selectSelectedEntityIdentifier); const bookmarked = useAppSelector(selectBookmarkedEntityIdentifier); - const imageViewer = useImageViewer(); // Update prev and current when selected entity changes useEffect(() => { @@ -49,7 +47,6 @@ export const useCanvasEntityQuickSwitchHotkey = () => { id: 'quickSwitch', category: 'canvas', callback: onQuickSwitch, - options: { enabled: !imageViewer.isOpen }, - dependencies: [onQuickSwitch, imageViewer.isOpen], + dependencies: [onQuickSwitch], }); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts index cfa648eb9f..b62d2d7dfd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts @@ -6,7 +6,6 @@ import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocke import { entityReset } from 'features/controlLayers/store/canvasSlice'; import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { isMaskEntityIdentifier } from 'features/controlLayers/store/types'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -17,7 +16,6 @@ export function useCanvasResetLayerHotkey() { const isBusy = useCanvasIsBusy(); const adapter = useEntityAdapterSafe(entityIdentifier); const isLocked = useEntityIsLocked(entityIdentifier); - const imageViewer = useImageViewer(); const resetSelectedLayer = useCallback(() => { if (entityIdentifier === null || adapter === null) { @@ -36,7 +34,7 @@ export function useCanvasResetLayerHotkey() { id: 'resetSelected', category: 'canvas', callback: resetSelectedLayer, - options: { enabled: isResetAllowed && !isBusy && !isLocked && !imageViewer.isOpen }, - dependencies: [isResetAllowed, isBusy, isLocked, resetSelectedLayer, imageViewer.isOpen], + options: { enabled: isResetAllowed && !isBusy && !isLocked }, + dependencies: [isResetAllowed, isBusy, isLocked, resetSelectedLayer], }); } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx index c5b1c03023..9e90a197e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx @@ -3,7 +3,6 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; @@ -12,7 +11,6 @@ export const useCanvasUndoRedoHotkeys = () => { useAssertSingleton('useCanvasUndoRedo'); const dispatch = useDispatch(); const isBusy = useCanvasIsBusy(); - const imageViewer = useImageViewer(); const mayUndo = useAppSelector(selectCanvasMayUndo); const handleUndo = useCallback(() => { @@ -22,8 +20,8 @@ export const useCanvasUndoRedoHotkeys = () => { id: 'undo', category: 'canvas', callback: handleUndo, - options: { enabled: mayUndo && !isBusy && !imageViewer.isOpen, preventDefault: true }, - dependencies: [mayUndo, isBusy, handleUndo, imageViewer.isOpen], + options: { enabled: mayUndo && !isBusy, preventDefault: true }, + dependencies: [mayUndo, isBusy, handleUndo], }); const mayRedo = useAppSelector(selectCanvasMayRedo); @@ -34,7 +32,7 @@ export const useCanvasUndoRedoHotkeys = () => { id: 'redo', category: 'canvas', callback: handleRedo, - options: { enabled: mayRedo && !isBusy && !imageViewer.isOpen, preventDefault: true }, - dependencies: [mayRedo, handleRedo, isBusy, imageViewer.isOpen], + options: { enabled: mayRedo && !isBusy, preventDefault: true }, + dependencies: [mayRedo, handleRedo, isBusy], }); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityFilter.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityFilter.ts index c86aa92dc2..e3da064b51 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityFilter.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityFilter.ts @@ -5,13 +5,11 @@ import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty' import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { isFilterableEntityIdentifier } from 'features/controlLayers/store/types'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useCallback, useMemo } from 'react'; export const useEntityFilter = (entityIdentifier: CanvasEntityIdentifier | null) => { const canvasManager = useCanvasManager(); const adapter = useEntityAdapterSafe(entityIdentifier); - const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusy(); const isLocked = useEntityIsLocked(entityIdentifier); const isEmpty = useEntityIsEmpty(entityIdentifier); @@ -52,9 +50,8 @@ export const useEntityFilter = (entityIdentifier: CanvasEntityIdentifier | null) if (!adapter) { return; } - imageViewer.close(); adapter.filterer.start(); - }, [isDisabled, entityIdentifier, canvasManager, imageViewer]); + }, [isDisabled, entityIdentifier, canvasManager]); return { isDisabled, start } as const; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntitySegmentAnything.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntitySegmentAnything.ts index 0893f02119..2959bb9b96 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntitySegmentAnything.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntitySegmentAnything.ts @@ -5,13 +5,11 @@ import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty' import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { isSegmentableEntityIdentifier } from 'features/controlLayers/store/types'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useCallback, useMemo } from 'react'; export const useEntitySegmentAnything = (entityIdentifier: CanvasEntityIdentifier | null) => { const canvasManager = useCanvasManager(); const adapter = useEntityAdapterSafe(entityIdentifier); - const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusy(); const isLocked = useEntityIsLocked(entityIdentifier); const isEmpty = useEntityIsEmpty(entityIdentifier); @@ -52,9 +50,8 @@ export const useEntitySegmentAnything = (entityIdentifier: CanvasEntityIdentifie if (!adapter) { return; } - imageViewer.close(); adapter.segmentAnything.start(); - }, [isDisabled, entityIdentifier, canvasManager, imageViewer]); + }, [isDisabled, entityIdentifier, canvasManager]); return { isDisabled, start } as const; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTransform.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTransform.ts index 5b7e3fbc74..624cb4395d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTransform.ts @@ -5,13 +5,11 @@ import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty' import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { isTransformableEntityIdentifier } from 'features/controlLayers/store/types'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useCallback, useMemo } from 'react'; export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | null) => { const canvasManager = useCanvasManager(); const adapter = useEntityAdapterSafe(entityIdentifier); - const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusy(); const isLocked = useEntityIsLocked(entityIdentifier); const isEmpty = useEntityIsEmpty(entityIdentifier); @@ -52,9 +50,8 @@ export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | nu if (!adapter) { return; } - imageViewer.close(); await adapter.transformer.startTransform(); - }, [isDisabled, entityIdentifier, canvasManager, imageViewer]); + }, [isDisabled, entityIdentifier, canvasManager]); const fitToBbox = useCallback(async () => { if (isDisabled) { @@ -70,11 +67,10 @@ export const useEntityTransform = (entityIdentifier: CanvasEntityIdentifier | nu if (!adapter) { return; } - imageViewer.close(); await adapter.transformer.startTransform({ silent: true }); adapter.transformer.fitToBboxContain(); await adapter.transformer.applyTransform(); - }, [canvasManager, entityIdentifier, imageViewer, isDisabled]); + }, [canvasManager, entityIdentifier, isDisabled]); return { isDisabled, start, fitToBbox } as const; }; diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 533bc2bd4c..5e7042cde7 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -8,6 +8,7 @@ import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreview import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { firefoxDndFix } from 'features/dnd/util'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo, useEffect, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -47,6 +48,9 @@ export const DndImage = memo(({ imageDTO, asThumbnail, ...rest }: DndImage.Props getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name), onDragStart: () => { setIsDragging(true); + if ($imageViewer.get()) { + $imageViewer.set(false); + } }, onDrop: () => { setIsDragging(false); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 5f080d1ab6..4264196bca 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -1,4 +1,4 @@ -import { Box, IconButton, type SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, IconButton, type SystemStyleObject, useOutsideClick } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; @@ -9,7 +9,7 @@ import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewe import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar'; import { selectHasImageToCompare } from 'features/gallery/store/gallerySelectors'; import type { ReactNode } from 'react'; -import { memo } from 'react'; +import { memo, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; @@ -43,7 +43,7 @@ export const ImageViewer = memo(({ closeButton }: Props) => { const [containerRef, containerDims] = useMeasure(); return ( - + {hasImageToCompare && } {!hasImageToCompare && } @@ -57,17 +57,50 @@ export const ImageViewer = memo(({ closeButton }: Props) => { ImageViewer.displayName = 'ImageViewer'; -export const GatedImageViewer = memo(() => { +const imageViewerContainerSx: SystemStyleObject = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + transition: 'opacity 0.15s ease', + opacity: 1, + pointerEvents: 'auto', + '&[data-hidden="true"]': { + opacity: 0, + pointerEvents: 'none', + }, + backdropFilter: 'blur(10px) brightness(70%)', +}; + +const imageViewerModalSx: SystemStyleObject = { + position: 'absolute', + bg: 'base.800', + borderRadius: 'base', + top: 16, + right: 16, + bottom: 16, + left: 16, +}; + +export const ImageViewerModal = memo(() => { + const ref = useRef(null); const imageViewer = useImageViewer(); + useOutsideClick({ + ref, + handler: imageViewer.close, + }); - if (!imageViewer.isOpen) { - return null; - } - - return } />; + return ( + + + } /> + + + ); }); -GatedImageViewer.displayName = 'GatedImageViewer'; +ImageViewerModal.displayName = 'GatedImageViewer'; const ImageViewerCloseButton = memo(() => { const { t } = useTranslation(); @@ -87,15 +120,3 @@ const ImageViewerCloseButton = memo(() => { }); ImageViewerCloseButton.displayName = 'ImageViewerCloseButton'; - -const GatedImageViewerCloseButton = memo(() => { - const imageViewer = useImageViewer(); - - if (!imageViewer.isOpen) { - return null; - } - - return ; -}); - -GatedImageViewerCloseButton.displayName = 'GatedImageViewerCloseButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts index 854326450f..038c5ee607 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts @@ -27,9 +27,10 @@ import type { ImageDTO } from 'services/api/types'; * TODO(psyche): Figure out a better way to do handle this... */ let didCloseImageViewer = false; -const api = buildUseBoolean(true); +const api = buildUseBoolean(false); const useImageViewerState = api[0]; export const $imageViewer = api[1]; +export const toggleImageViewer = () => $imageViewer.set(!$imageViewer.get()); export const useImageViewer = () => { const dispatch = useAppDispatch(); diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx index a731d40ad9..61a118881e 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx @@ -2,7 +2,6 @@ import { ButtonGroup, Flex, Icon, IconButton, spinAnimation, Tooltip, useShiftMo import { useAppSelector } from 'app/store/storeHooks'; import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { useClearQueueDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip'; @@ -30,12 +29,11 @@ type Props = { const FloatingSidePanelButtons = ({ togglePanel }: Props) => { const { t } = useTranslation(); const tab = useAppSelector(selectActiveTab); - const imageViewer = useImageViewer(); const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll'); return ( - {tab === 'canvas' && !imageViewer.isOpen && ( + {tab === 'canvas' && ( From 341910739ec6fee3dbb2267d8ca98247f4d7f649 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 16 May 2025 12:24:22 +1000 Subject: [PATCH 002/210] chore(ui): bump @reduxjs/toolkit to latest --- invokeai/frontend/web/package.json | 2 +- invokeai/frontend/web/pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 3d1afd62ae..f7c2513edb 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -60,7 +60,7 @@ "@fontsource-variable/inter": "^5.2.5", "@invoke-ai/ui-library": "^0.0.46", "@nanostores/react": "^1.0.0", - "@reduxjs/toolkit": "2.7.0", + "@reduxjs/toolkit": "2.8.2", "@roarr/browser-log-writer": "^1.3.0", "@xyflow/react": "^12.6.0", "async-mutex": "^0.5.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index bb5722c934..79de0c48c1 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -30,8 +30,8 @@ dependencies: specifier: ^1.0.0 version: 1.0.0(nanostores@1.0.1)(react@18.3.1) '@reduxjs/toolkit': - specifier: 2.7.0 - version: 2.7.0(react-redux@9.2.0)(react@18.3.1) + specifier: 2.8.2 + version: 2.8.2(react-redux@9.2.0)(react@18.3.1) '@roarr/browser-log-writer': specifier: ^1.3.0 version: 1.3.0 @@ -2161,8 +2161,8 @@ packages: - supports-color dev: true - /@reduxjs/toolkit@2.7.0(react-redux@9.2.0)(react@18.3.1): - resolution: {integrity: sha512-XVwolG6eTqwV0N8z/oDlN93ITCIGIop6leXlGJI/4EKy+0POYkR+ABHRSdGXY+0MQvJBP8yAzh+EYFxTuvmBiQ==} + /@reduxjs/toolkit@2.8.2(react-redux@9.2.0)(react@18.3.1): + resolution: {integrity: sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==} peerDependencies: react: ^16.9.0 || ^17.0.0 || ^18 || ^19 react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 From 668c475271dd07ba9571a68aacd1321b47e1d6dd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 16 May 2025 12:39:18 +1000 Subject: [PATCH 003/210] feat(ui): default canvas tool is move --- .../features/controlLayers/konva/CanvasTool/CanvasToolModule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts index 8ef2f44b3a..762ae693d3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts @@ -70,7 +70,7 @@ export class CanvasToolModule extends CanvasModuleBase { /** * The currently selected tool. */ - $tool = atom('brush'); + $tool = atom('move'); /** * A buffer for the currently selected tool. This is used to temporarily store the tool while the user is using any * hold-to-activate tools, like the view or color picker tools. From c9cd0a87be3f1876479651bdddae9d9bc00d05ea Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 21 May 2025 16:17:20 +1000 Subject: [PATCH 004/210] refactor(ui): image viewer & comparison convolutedness --- .../ImageViewer/CurrentImagePreview.tsx | 8 +-- .../ImageViewer/ImageComparison.tsx | 48 ++++++++------ .../ImageViewer/ImageComparisonSideBySide.tsx | 58 ++++++++--------- .../ImageViewer/ImageComparisonSlider.tsx | 1 + .../components/ImageViewer/ImageViewer.tsx | 62 +++++++++---------- .../gallery/components/ImageViewer/common.ts | 6 ++ 6 files changed, 89 insertions(+), 94 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index ea682485b2..5e26c69311 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -1,29 +1,23 @@ import { Box, Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { CanvasAlertsSendingToCanvas } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; -import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useRef, useState } from 'react'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import { $hasProgressImage, $isProgressFromCanvas } from 'services/events/stores'; import { NoContentForViewer } from './NoContentForViewer'; import ProgressImage from './ProgressImage'; -const CurrentImagePreview = () => { +const CurrentImagePreview = ({ imageDTO }: { imageDTO?: ImageDTO }) => { const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); - const imageName = useAppSelector(selectLastSelectedImageName); - - const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); // Show and hide the next/prev buttons on mouse move const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx index 6ebb4ab15c..4681a4bafb 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx @@ -1,42 +1,50 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import type { Dimensions } from 'features/controlLayers/store/types'; -import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; +import type { ComparisonProps } from 'features/gallery/components/ImageViewer/common'; +import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar'; +import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable'; import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover'; import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide'; import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider'; import { selectComparisonMode } from 'features/gallery/store/gallerySelectors'; import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiImagesBold } from 'react-icons/pi'; +import { useMeasure } from 'react-use'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; -type Props = { - containerDims: Dimensions; -}; - -export const ImageComparison = memo(({ containerDims }: Props) => { - const { t } = useTranslation(); +export const ImageComparisonContent = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => { const comparisonMode = useAppSelector(selectComparisonMode); - const { firstImage, secondImage } = useAppSelector(selectComparisonImages); - - if (!firstImage || !secondImage) { - // Should rarely/never happen - we don't render this component unless we have images to compare - return ; - } if (comparisonMode === 'slider') { - return ; + return ; } if (comparisonMode === 'side-by-side') { return ( - + ); } if (comparisonMode === 'hover') { - return ; + return ; } + + assert>(false); }); +ImageComparisonContent.displayName = 'ImageComparisonContent'; + +export const ImageComparison = memo(({ firstImage, secondImage }: Omit) => { + const [containerRef, containerDims] = useMeasure(); + + return ( + + + + + + + + ); +}); ImageComparison.displayName = 'ImageComparison'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx index 522cf3ddd0..a84e842dcc 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx @@ -5,6 +5,7 @@ import { VerticalResizeHandle } from 'features/ui/components/tabs/ResizeHandle'; import { memo, useCallback, useRef } from 'react'; import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels'; +import type { ImageDTO } from 'services/api/types'; export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: ComparisonProps) => { const panelGroupRef = useRef(null); @@ -25,42 +26,11 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Comp autoSaveId="image-comparison-side-by-side" > - - - - - - + - - - - - - - + @@ -69,3 +39,25 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Comp }); ImageComparisonSideBySide.displayName = 'ImageComparisonSideBySide'; + +const SideBySideImage = memo(({ imageDTO, type }: { imageDTO: ImageDTO; type: 'first' | 'second' }) => { + return ( + + + + + + + ); +}); +SideBySideImage.displayName = 'SideBySideImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx index 3bbcf6037f..35361ff23a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx @@ -21,6 +21,7 @@ const HANDLE_LEFT_INITIAL_PX = `calc(${INITIAL_POS} - ${HANDLE_HITBOX / 2}px)`; export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => { const comparisonFit = useAppSelector(selectComparisonFit); + // How far the handle is from the left - this will be a CSS calculation that takes into account the handle width const [left, setLeft] = useState(HANDLE_LEFT_INITIAL_PX); // How wide the first image is diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 4264196bca..34d9329532 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -1,19 +1,18 @@ -import { Box, IconButton, type SystemStyleObject, useOutsideClick } from '@invoke-ai/ui-library'; +import { Box, Flex, IconButton, type SystemStyleObject, useOutsideClick } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; -import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; -import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar'; +import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common'; import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview'; import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison'; -import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable'; import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar'; -import { selectHasImageToCompare } from 'features/gallery/store/gallerySelectors'; +import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; import type { ReactNode } from 'react'; import { memo, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -import { useMeasure } from 'react-use'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useImageViewer } from './useImageViewer'; @@ -37,22 +36,16 @@ const FOCUS_REGION_STYLES: SystemStyleObject = { overflow: 'hidden', }; -export const ImageViewer = memo(({ closeButton }: Props) => { - useAssertSingleton('ImageViewer'); - const hasImageToCompare = useAppSelector(selectHasImageToCompare); - const [containerRef, containerDims] = useMeasure(); +export const ImageViewer = memo(() => { + const lastSelectedImageName = useAppSelector(selectLastSelectedImageName); + const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken); + const comparisonImageDTO = useAppSelector(selectImageToCompare); - return ( - - {hasImageToCompare && } - {!hasImageToCompare && } - - {!hasImageToCompare && } - {hasImageToCompare && } - - - - ); + if (lastSelectedImageDTO && comparisonImageDTO) { + return ; + } + + return ; }); ImageViewer.displayName = 'ImageViewer'; @@ -73,16 +66,6 @@ const imageViewerContainerSx: SystemStyleObject = { backdropFilter: 'blur(10px) brightness(70%)', }; -const imageViewerModalSx: SystemStyleObject = { - position: 'absolute', - bg: 'base.800', - borderRadius: 'base', - top: 16, - right: 16, - bottom: 16, - left: 16, -}; - export const ImageViewerModal = memo(() => { const ref = useRef(null); const imageViewer = useImageViewer(); @@ -93,9 +76,20 @@ export const ImageViewerModal = memo(() => { return ( - - } /> - + + + + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts index ac3d7b172b..3170b08b16 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts @@ -1,3 +1,4 @@ +import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import type { Dimensions } from 'features/controlLayers/store/types'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; @@ -62,3 +63,8 @@ export const selectComparisonImages = createMemoizedSelector(selectGallerySlice, const secondImage = gallerySlice.imageToCompare; return { firstImage, secondImage }; }); +export const selectFirstImage = createSelector( + selectGallerySlice, + (gallerySlice) => gallerySlice.selection.slice(-1)[0] ?? null +); +export const selectImageToCompare = createSelector(selectGallerySlice, (gallerySlice) => gallerySlice.imageToCompare); From 5f2f12f803eb0acf10d05d8dfae18ad1033511bb Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 21 May 2025 16:26:58 +1000 Subject: [PATCH 005/210] refactor(ui): org state in prep for new flow --- .../frontend/web/src/common/util/zodUtils.ts | 10 ++ .../controlLayers/store/canvasSlice.ts | 54 +------- .../features/controlLayers/store/selectors.ts | 79 +++++++----- .../src/features/controlLayers/store/types.ts | 117 ++++++++++++------ .../store/dynamicPromptsSlice.ts | 3 +- .../parameters/types/parameterSchemas.ts | 12 +- 6 files changed, 145 insertions(+), 130 deletions(-) create mode 100644 invokeai/frontend/web/src/common/util/zodUtils.ts diff --git a/invokeai/frontend/web/src/common/util/zodUtils.ts b/invokeai/frontend/web/src/common/util/zodUtils.ts new file mode 100644 index 0000000000..10506736e1 --- /dev/null +++ b/invokeai/frontend/web/src/common/util/zodUtils.ts @@ -0,0 +1,10 @@ +import type { z } from 'zod'; + +/** + * Helper to create a type guard from a zod schema. The type guard will infer the schema's TS type. + * @param schema The zod schema to create a type guard from. + * @returns A type guard function for the schema. + */ +export const buildZodTypeGuard = (schema: T) => { + return (val: unknown): val is z.infer => schema.safeParse(val).success; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 5fd71f8158..cbcd0dcb26 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -70,7 +70,9 @@ import type { T2IAdapterConfig, } from './types'; import { + DEFAULT_ASPECT_RATIO_CONFIG, getEntityIdentifier, + getInitialState, isChatGPT4oAspectRatioID, isFluxKontextAspectRatioID, isImagenAspectRatioID, @@ -93,55 +95,9 @@ import { initialT2IAdapter, } from './util'; -/** - * Gets a fresh canvas initial state with no references in memory to existing objects. - */ -const getInitialState = (): CanvasState => { - const initialInpaintMaskState = getInpaintMaskState(getPrefixedId('inpaint_mask')); - const initialState: CanvasState = { - _version: 3, - selectedEntityIdentifier: getEntityIdentifier(initialInpaintMaskState), - bookmarkedEntityIdentifier: getEntityIdentifier(initialInpaintMaskState), - rasterLayers: { - isHidden: false, - entities: [], - }, - controlLayers: { - isHidden: false, - entities: [], - }, - inpaintMasks: { - isHidden: false, - entities: [initialInpaintMaskState], - }, - regionalGuidance: { - isHidden: false, - entities: [], - }, - referenceImages: { entities: [] }, - bbox: { - rect: { x: 0, y: 0, width: 512, height: 512 }, - aspectRatio: { - id: '1:1', - value: 1, - isLocked: false, - }, - scaleMethod: 'auto', - scaledSize: { - width: 512, - height: 512, - }, - modelBase: 'sd-1', - }, - }; - return initialState; -}; - -const initialState = getInitialState(); - export const canvasSlice = createSlice({ name: 'canvas', - initialState, + initialState: getInitialState(), reducers: { // undoable canvas state //#region Raster layers @@ -1409,7 +1365,7 @@ export const canvasSlice = createSlice({ state.bbox.rect.width = width; state.bbox.rect.height = height; } else { - state.bbox.aspectRatio = deepClone(initialState.bbox.aspectRatio); + state.bbox.aspectRatio = deepClone(DEFAULT_ASPECT_RATIO_CONFIG); state.bbox.rect.width = optimalDimension; state.bbox.rect.height = optimalDimension; } @@ -1975,7 +1931,7 @@ const migrate = (state: any): any => { export const canvasPersistConfig: PersistConfig = { name: canvasSlice.name, - initialState, + initialState: getInitialState(), migrate, persistDenylist: [], }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts index 32114001e3..fe37d1891b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts @@ -1,3 +1,4 @@ +import type { Selector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; @@ -23,6 +24,9 @@ import { assert } from 'tsafe'; */ export const selectCanvasSlice = (state: RootState) => state.canvas.present; +export const createCanvasSelector = (selector: Selector) => + createSelector(selectCanvasSlice, selector); + /** * Selects the total canvas entity count: * - Regions @@ -33,7 +37,7 @@ export const selectCanvasSlice = (state: RootState) => state.canvas.present; * * All entities are counted, regardless of their state. */ -const selectEntityCountAll = createSelector(selectCanvasSlice, (canvas) => { +const selectEntityCountAll = createCanvasSelector((canvas) => { return ( canvas.regionalGuidance.entities.length + canvas.referenceImages.entities.length + @@ -45,24 +49,29 @@ const selectEntityCountAll = createSelector(selectCanvasSlice, (canvas) => { const isVisibleEntity = (entity: CanvasRenderableEntityState) => entity.isEnabled && entity.objects.length > 0; -export const selectActiveRasterLayerEntities = createSelector(selectCanvasSlice, (canvas) => - canvas.rasterLayers.entities.filter(isVisibleEntity) +export const selectRasterLayerEntities = createCanvasSelector((canvas) => canvas.rasterLayers.entities); +export const selectActiveRasterLayerEntities = createSelector(selectRasterLayerEntities, (entities) => + entities.filter(isVisibleEntity) ); -export const selectActiveControlLayerEntities = createSelector(selectCanvasSlice, (canvas) => - canvas.controlLayers.entities.filter(isVisibleEntity) +export const selectControlLayerEntities = createCanvasSelector((canvas) => canvas.controlLayers.entities); +export const selectActiveControlLayerEntities = createSelector(selectControlLayerEntities, (entities) => + entities.filter(isVisibleEntity) ); -export const selectActiveInpaintMaskEntities = createSelector(selectCanvasSlice, (canvas) => - canvas.inpaintMasks.entities.filter(isVisibleEntity) +export const selectInpaintMaskEntities = createCanvasSelector((canvas) => canvas.inpaintMasks.entities); +export const selectActiveInpaintMaskEntities = createSelector(selectInpaintMaskEntities, (entities) => + entities.filter(isVisibleEntity) ); -export const selectActiveRegionalGuidanceEntities = createSelector(selectCanvasSlice, (canvas) => - canvas.regionalGuidance.entities.filter(isVisibleEntity) +export const selectRegionalGuidanceEntities = createCanvasSelector((canvas) => canvas.regionalGuidance.entities); +export const selectActiveRegionalGuidanceEntities = createSelector(selectRegionalGuidanceEntities, (entities) => + entities.filter(isVisibleEntity) ); -export const selectActiveReferenceImageEntities = createSelector(selectCanvasSlice, (canvas) => - canvas.referenceImages.entities.filter((e) => e.isEnabled) +export const selectReferenceImageEntities = createCanvasSelector((canvas) => canvas.referenceImages.entities); +export const selectActiveReferenceImageEntities = createSelector(selectReferenceImageEntities, (entities) => + entities.filter((e) => e.isEnabled) ); /** @@ -192,14 +201,6 @@ export function selectEntityIdentifierBelowThisOne | undefined; } -export const selectRasterLayerEntities = createSelector(selectCanvasSlice, (canvas) => canvas.rasterLayers.entities); -export const selectControlLayerEntities = createSelector(selectCanvasSlice, (canvas) => canvas.controlLayers.entities); -export const selectInpaintMaskEntities = createSelector(selectCanvasSlice, (canvas) => canvas.inpaintMasks.entities); -export const selectRegionalGuidanceEntities = createSelector( - selectCanvasSlice, - (canvas) => canvas.regionalGuidance.entities -); - /** * Selected an entity from the canvas slice. If the entity is not found, an error is thrown. * @@ -218,7 +219,7 @@ export function selectEntityOrThrow( } export const selectEntityExists = (entityIdentifier: T) => { - return createSelector(selectCanvasSlice, (canvas) => Boolean(selectEntity(canvas, entityIdentifier))); + return createCanvasSelector((canvas) => Boolean(selectEntity(canvas, entityIdentifier))); }; /** @@ -299,7 +300,7 @@ export function selectRegionalGuidanceReferenceImage( return entity.referenceImages.find(({ id }) => id === referenceImageId); } -export const selectBbox = createSelector(selectCanvasSlice, (canvas) => canvas.bbox); +export const selectBbox = createCanvasSelector((canvas) => canvas.bbox); export const selectSelectedEntityIdentifier = createSelector( selectCanvasSlice, @@ -331,10 +332,10 @@ export const selectSelectedEntityFill = createSelector( } ); -const selectRasterLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.rasterLayers.isHidden); -const selectControlLayersIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.controlLayers.isHidden); -const selectInpaintMasksIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.inpaintMasks.isHidden); -const selectRegionalGuidanceIsHidden = createSelector(selectCanvasSlice, (canvas) => canvas.regionalGuidance.isHidden); +const selectRasterLayersIsHidden = createCanvasSelector((canvas) => canvas.rasterLayers.isHidden); +const selectControlLayersIsHidden = createCanvasSelector((canvas) => canvas.controlLayers.isHidden); +const selectInpaintMasksIsHidden = createCanvasSelector((canvas) => canvas.inpaintMasks.isHidden); +const selectRegionalGuidanceIsHidden = createCanvasSelector((canvas) => canvas.regionalGuidance.isHidden); /** * Returns the hidden selector for the given entity type. @@ -372,7 +373,7 @@ export const buildSelectIsSelected = (entityIdentifier: CanvasEntityIdentifier) * Other entities are considered empty if they have no objects. */ export const buildSelectHasObjects = (entityIdentifier: CanvasEntityIdentifier) => { - return createSelector(selectCanvasSlice, (canvas) => { + return createCanvasSelector((canvas) => { const entity = selectEntity(canvas, entityIdentifier); if (!entity) { @@ -385,10 +386,10 @@ export const buildSelectHasObjects = (entityIdentifier: CanvasEntityIdentifier) }); }; -export const selectWidth = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.rect.width); -export const selectHeight = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.rect.height); -export const selectAspectRatioID = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.aspectRatio.id); -export const selectAspectRatioValue = createSelector(selectCanvasSlice, (canvas) => canvas.bbox.aspectRatio.value); +export const selectWidth = createCanvasSelector((canvas) => canvas.bbox.rect.width); +export const selectHeight = createCanvasSelector((canvas) => canvas.bbox.rect.height); +export const selectAspectRatioID = createCanvasSelector((canvas) => canvas.bbox.aspectRatio.id); +export const selectAspectRatioValue = createCanvasSelector((canvas) => canvas.bbox.aspectRatio.value); export const selectScaledSize = createSelector(selectBbox, (bbox) => bbox.scaledSize); export const selectScaleMethod = createSelector(selectBbox, (bbox) => bbox.scaleMethod); export const selectBboxRect = createSelector(selectBbox, (bbox) => bbox.rect); @@ -407,3 +408,21 @@ export const selectCanvasMetadata = createSelector( return { canvas_v2_metadata }; } ); + +export const selectIsSessionStarted = createCanvasSelector(({ isSessionStarted }) => isSessionStarted); +export const selectIsCanvasEmpty = createCanvasSelector( + ({ controlLayers, inpaintMasks, rasterLayers, regionalGuidance }) => { + // Check it all manually - could use lodash isEqual, but this selector will be called very often! + // Also note - we do not care about ref images, as they are technically not part of canvas + return ( + controlLayers.entities.length === 0 && + controlLayers.isHidden === false && + inpaintMasks.entities.length === 0 && + inpaintMasks.isHidden === false && + rasterLayers.entities.length === 0 && + rasterLayers.isHidden === false && + regionalGuidance.entities.length === 0 && + regionalGuidance.isHidden === false + ); + } +); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 9d0f79b8a8..a4aee48086 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -1,3 +1,4 @@ +import { deepClone } from 'common/util/deepClone'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; import { fetchModelConfigByIdentifier } from 'features/metadata/util/modelFetchingHelpers'; import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; @@ -438,51 +439,87 @@ export const isFluxKontextAspectRatioID = (v: unknown): v is z.infer; export const isAspectRatioID = (v: unknown): v is AspectRatioID => zAspectRatioID.safeParse(v).success; +const zAspectRatioConfig = z.object({ + id: zAspectRatioID, + value: z.number().gt(0), + isLocked: z.boolean(), +}); +type AspectRatioConfig = z.infer; + +export const DEFAULT_ASPECT_RATIO_CONFIG: AspectRatioConfig = { + id: '1:1', + value: 1, + isLocked: false, +}; + +const zBboxState = z.object({ + rect: z.object({ + x: z.number().int(), + y: z.number().int(), + width: zParameterImageDimension, + height: zParameterImageDimension, + }), + aspectRatio: zAspectRatioConfig, + scaledSize: z.object({ + width: zParameterImageDimension, + height: zParameterImageDimension, + }), + scaleMethod: zBoundingBoxScaleMethod, + modelBase: zMainModelBase, +}); const zCanvasState = z.object({ - _version: z.literal(3), - selectedEntityIdentifier: zCanvasEntityIdentifer.nullable(), - bookmarkedEntityIdentifier: zCanvasEntityIdentifer.nullable(), - inpaintMasks: z.object({ - isHidden: z.boolean(), - entities: z.array(zCanvasInpaintMaskState), - }), - rasterLayers: z.object({ - isHidden: z.boolean(), - entities: z.array(zCanvasRasterLayerState), - }), - controlLayers: z.object({ - isHidden: z.boolean(), - entities: z.array(zCanvasControlLayerState), - }), - regionalGuidance: z.object({ - isHidden: z.boolean(), - entities: z.array(zCanvasRegionalGuidanceState), - }), - referenceImages: z.object({ - entities: z.array(zCanvasReferenceImageState), - }), - bbox: z.object({ - rect: z.object({ - x: z.number().int(), - y: z.number().int(), - width: zParameterImageDimension, - height: zParameterImageDimension, - }), - aspectRatio: z.object({ - id: zAspectRatioID, - value: z.number().gt(0), - isLocked: z.boolean(), - }), - scaledSize: z.object({ - width: zParameterImageDimension, - height: zParameterImageDimension, - }), - scaleMethod: zBoundingBoxScaleMethod, - modelBase: zMainModelBase, + _version: z.literal(3).default(3), + isSessionStarted: z.boolean().default(false), + selectedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null), + bookmarkedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null), + inpaintMasks: z + .object({ + isHidden: z.boolean(), + entities: z.array(zCanvasInpaintMaskState), + }) + .default({ isHidden: false, entities: [] }), + rasterLayers: z + .object({ + isHidden: z.boolean(), + entities: z.array(zCanvasRasterLayerState), + }) + .default({ isHidden: false, entities: [] }), + controlLayers: z + .object({ + isHidden: z.boolean(), + entities: z.array(zCanvasControlLayerState), + }) + .default({ isHidden: false, entities: [] }), + regionalGuidance: z + .object({ + isHidden: z.boolean(), + entities: z.array(zCanvasRegionalGuidanceState), + }) + .default({ isHidden: false, entities: [] }), + referenceImages: z + .object({ + entities: z.array(zCanvasReferenceImageState), + }) + .default({ entities: [] }), + bbox: zBboxState.default({ + rect: { x: 0, y: 0, width: 512, height: 512 }, + aspectRatio: DEFAULT_ASPECT_RATIO_CONFIG, + scaleMethod: 'auto', + scaledSize: { + width: 512, + height: 512, + }, + modelBase: 'sd-1', }), }); export type CanvasState = z.infer; +/** + * Gets a fresh canvas initial state with no references in memory to existing objects. + */ +const CANVAS_INITIAL_STATE = zCanvasState.parse({}); +export const getInitialState = () => deepClone(CANVAS_INITIAL_STATE); + export const zCanvasMetadata = z.object({ inpaintMasks: z.array(zCanvasInpaintMaskState), rasterLayers: z.array(zCanvasRasterLayerState), diff --git a/invokeai/frontend/web/src/features/dynamicPrompts/store/dynamicPromptsSlice.ts b/invokeai/frontend/web/src/features/dynamicPrompts/store/dynamicPromptsSlice.ts index 9f95039c4f..741c415f38 100644 --- a/invokeai/frontend/web/src/features/dynamicPrompts/store/dynamicPromptsSlice.ts +++ b/invokeai/frontend/web/src/features/dynamicPrompts/store/dynamicPromptsSlice.ts @@ -1,11 +1,12 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; +import { buildZodTypeGuard } from 'common/util/zodUtils'; import { z } from 'zod'; const zSeedBehaviour = z.enum(['PER_ITERATION', 'PER_PROMPT']); +export const isSeedBehaviour = buildZodTypeGuard(zSeedBehaviour); export type SeedBehaviour = z.infer; -export const isSeedBehaviour = (v: unknown): v is SeedBehaviour => zSeedBehaviour.safeParse(v).success; export interface DynamicPromptsState { _version: 1; diff --git a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts index 1076100947..acc02785b9 100644 --- a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts +++ b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts @@ -1,5 +1,6 @@ import { NUMPY_RAND_MAX } from 'app/constants'; import { roundToMultiple } from 'common/util/roundDownToMultiple'; +import { buildZodTypeGuard } from 'common/util/zodUtils'; import { zModelIdentifierField, zSchedulerField } from 'features/nodes/types/common'; import { z } from 'zod'; @@ -15,21 +16,12 @@ import { z } from 'zod'; * simply be the zod schema's safeParse function */ -/** - * Helper to create a type guard from a zod schema. The type guard will infer the schema's TS type. - * @param schema The zod schema to create a type guard from. - * @returns A type guard function for the schema. - */ -export const buildTypeGuard = (schema: T) => { - return (val: unknown): val is z.infer => schema.safeParse(val).success; -}; - /** * Helper to create a zod schema and a type guard from it. * @param schema The zod schema to create a type guard from. * @returns A tuple containing the zod schema and the type guard function. */ -const buildParameter = (schema: T) => [schema, buildTypeGuard(schema)] as const; +export const buildParameter = (schema: T) => [schema, buildZodTypeGuard(schema)] as const; // #region Positive prompt export const [zParameterPositivePrompt, isParameterPositivePrompt] = buildParameter(z.string()); From 5c4cbc7fa22bc14429c62317ff38344353c8c54e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 21 May 2025 16:38:07 +1000 Subject: [PATCH 006/210] refactor(ui): zod-ify params slice state --- .../controlLayers/store/paramsSlice.ts | 189 ++++++++---------- .../src/features/controlLayers/store/types.ts | 2 +- .../parameters/types/parameterSchemas.ts | 4 +- 3 files changed, 84 insertions(+), 111 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 069e8fc366..72060f38ad 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -2,7 +2,7 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import type { RgbaColor } from 'features/controlLayers/store/types'; +import { type RgbaColor, zRgbaColor } from 'features/controlLayers/store/types'; import { CLIP_SKIP_MAP } from 'features/parameters/types/constants'; import type { ParameterCanvasCoherenceMode, @@ -13,126 +13,99 @@ import type { ParameterCLIPLEmbedModel, ParameterControlLoRAModel, ParameterGuidance, - ParameterMaskBlurMethod, ParameterModel, - ParameterNegativePrompt, - ParameterNegativeStylePromptSDXL, - ParameterPositivePrompt, - ParameterPositiveStylePromptSDXL, ParameterPrecision, ParameterScheduler, ParameterSDXLRefinerModel, - ParameterSeed, - ParameterSteps, - ParameterStrength, ParameterT5EncoderModel, ParameterVAEModel, } from 'features/parameters/types/parameterSchemas'; +import { + zParameterCanvasCoherenceMode, + zParameterCFGRescaleMultiplier, + zParameterCFGScale, + zParameterCLIPEmbedModel, + zParameterCLIPGEmbedModel, + zParameterCLIPLEmbedModel, + zParameterControlLoRAModel, + zParameterGuidance, + zParameterMaskBlurMethod, + zParameterModel, + zParameterNegativePrompt, + zParameterNegativeStylePromptSDXL, + zParameterPositivePrompt, + zParameterPositiveStylePromptSDXL, + zParameterPrecision, + zParameterScheduler, + zParameterSDXLRefinerModel, + zParameterSeed, + zParameterSteps, + zParameterStrength, + zParameterT5EncoderModel, + zParameterVAEModel, +} from 'features/parameters/types/parameterSchemas'; import { clamp } from 'lodash-es'; +import { z } from 'zod'; import { newSessionRequested } from './actions'; -export type ParamsState = { - maskBlur: number; - maskBlurMethod: ParameterMaskBlurMethod; - canvasCoherenceMode: ParameterCanvasCoherenceMode; - canvasCoherenceMinDenoise: ParameterStrength; - canvasCoherenceEdgeSize: number; - infillMethod: string; - infillTileSize: number; - infillPatchmatchDownscaleSize: number; - infillColorValue: RgbaColor; - cfgScale: ParameterCFGScale; - cfgRescaleMultiplier: ParameterCFGRescaleMultiplier; - guidance: ParameterGuidance; - img2imgStrength: ParameterStrength; - optimizedDenoisingEnabled: boolean; - iterations: number; - scheduler: ParameterScheduler; - upscaleScheduler: ParameterScheduler; - upscaleCfgScale: ParameterCFGScale; - seed: ParameterSeed; - shouldRandomizeSeed: boolean; - steps: ParameterSteps; - model: ParameterModel | null; - vae: ParameterVAEModel | null; - vaePrecision: ParameterPrecision; - fluxVAE: ParameterVAEModel | null; - seamlessXAxis: boolean; - seamlessYAxis: boolean; - clipSkip: number; - shouldUseCpuNoise: boolean; - positivePrompt: ParameterPositivePrompt; - negativePrompt: ParameterNegativePrompt; - positivePrompt2: ParameterPositiveStylePromptSDXL; - negativePrompt2: ParameterNegativeStylePromptSDXL; - shouldConcatPrompts: boolean; - refinerModel: ParameterSDXLRefinerModel | null; - refinerSteps: number; - refinerCFGScale: number; - refinerScheduler: ParameterScheduler; - refinerPositiveAestheticScore: number; - refinerNegativeAestheticScore: number; - refinerStart: number; - t5EncoderModel: ParameterT5EncoderModel | null; - clipEmbedModel: ParameterCLIPEmbedModel | null; - clipLEmbedModel: ParameterCLIPLEmbedModel | null; - clipGEmbedModel: ParameterCLIPGEmbedModel | null; - controlLora: ParameterControlLoRAModel | null; -}; +const zParamsState = z.object({ + maskBlur: z.number().default(16), + maskBlurMethod: zParameterMaskBlurMethod.default('box'), + canvasCoherenceMode: zParameterCanvasCoherenceMode.default('Gaussian Blur'), + canvasCoherenceMinDenoise: zParameterStrength.default(0), + canvasCoherenceEdgeSize: z.number().default(16), + infillMethod: z.string().default('lama'), + infillTileSize: z.number().default(32), + infillPatchmatchDownscaleSize: z.number().default(1), + infillColorValue: zRgbaColor.default({ r: 0, g: 0, b: 0, a: 1 }), + cfgScale: zParameterCFGScale.default(7.5), + cfgRescaleMultiplier: zParameterCFGRescaleMultiplier.default(0), + guidance: zParameterGuidance.default(4), + img2imgStrength: zParameterStrength.default(0.75), + optimizedDenoisingEnabled: z.boolean().default(true), + iterations: z.number().default(1), + scheduler: zParameterScheduler.default('dpmpp_3m_k'), + upscaleScheduler: zParameterScheduler.default('kdpm_2'), + upscaleCfgScale: zParameterCFGScale.default(2), + seed: zParameterSeed.default(0), + shouldRandomizeSeed: z.boolean().default(true), + steps: zParameterSteps.default(30), + model: zParameterModel.nullable().default(null), + vae: zParameterVAEModel.nullable().default(null), + vaePrecision: zParameterPrecision.default('fp32'), + fluxVAE: zParameterVAEModel.nullable().default(null), + seamlessXAxis: z.boolean().default(false), + seamlessYAxis: z.boolean().default(false), + clipSkip: z.number().default(0), + shouldUseCpuNoise: z.boolean().default(true), + positivePrompt: zParameterPositivePrompt.default(''), + negativePrompt: zParameterNegativePrompt.default(''), + positivePrompt2: zParameterPositiveStylePromptSDXL.default(''), + negativePrompt2: zParameterNegativeStylePromptSDXL.default(''), + shouldConcatPrompts: z.boolean().default(true), + refinerModel: zParameterSDXLRefinerModel.nullable().default(null), + refinerSteps: z.number().default(20), + refinerCFGScale: z.number().default(7.5), + refinerScheduler: zParameterScheduler.default('euler'), + refinerPositiveAestheticScore: z.number().default(6), + refinerNegativeAestheticScore: z.number().default(2.5), + refinerStart: z.number().default(0.8), + t5EncoderModel: zParameterT5EncoderModel.nullable().default(null), + clipEmbedModel: zParameterCLIPEmbedModel.nullable().default(null), + clipLEmbedModel: zParameterCLIPLEmbedModel.nullable().default(null), + clipGEmbedModel: zParameterCLIPGEmbedModel.nullable().default(null), + controlLora: zParameterControlLoRAModel.nullable().default(null), +}); -const initialState: ParamsState = { - maskBlur: 16, - maskBlurMethod: 'box', - canvasCoherenceMode: 'Gaussian Blur', - canvasCoherenceMinDenoise: 0, - canvasCoherenceEdgeSize: 16, - infillMethod: 'lama', - infillTileSize: 32, - infillPatchmatchDownscaleSize: 1, - infillColorValue: { r: 0, g: 0, b: 0, a: 1 }, - cfgScale: 7.5, - cfgRescaleMultiplier: 0, - guidance: 4, - img2imgStrength: 0.75, - optimizedDenoisingEnabled: true, - iterations: 1, - scheduler: 'dpmpp_3m_k', - upscaleScheduler: 'kdpm_2', - upscaleCfgScale: 2, - seed: 0, - shouldRandomizeSeed: true, - steps: 30, - model: null, - vae: null, - fluxVAE: null, - vaePrecision: 'fp32', - seamlessXAxis: false, - seamlessYAxis: false, - clipSkip: 0, - shouldUseCpuNoise: true, - positivePrompt: '', - negativePrompt: '', - positivePrompt2: '', - negativePrompt2: '', - shouldConcatPrompts: true, - refinerModel: null, - refinerSteps: 20, - refinerCFGScale: 7.5, - refinerScheduler: 'euler', - refinerPositiveAestheticScore: 6, - refinerNegativeAestheticScore: 2.5, - refinerStart: 0.8, - t5EncoderModel: null, - clipEmbedModel: null, - clipLEmbedModel: null, - clipGEmbedModel: null, - controlLora: null, -}; +export type ParamsState = z.infer; + +const INITIAL_STATE = zParamsState.parse({}); +const getInitialState = () => deepClone(INITIAL_STATE); export const paramsSlice = createSlice({ name: 'params', - initialState, + initialState: getInitialState(), reducers: { setIterations: (state, action: PayloadAction) => { state.iterations = action.payload; @@ -300,7 +273,7 @@ export const paramsSlice = createSlice({ const resetState = (state: ParamsState): ParamsState => { // When a new session is requested, we need to keep the current model selections, plus dependent state // like VAE precision. Everything else gets reset to default. - const newState = deepClone(initialState); + const newState = getInitialState(); newState.model = state.model; newState.vae = state.vae; newState.fluxVAE = state.fluxVAE; @@ -366,7 +339,7 @@ const migrate = (state: any): any => { export const paramsPersistConfig: PersistConfig = { name: paramsSlice.name, - initialState, + initialState: getInitialState(), migrate, persistDenylist: [], }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index a4aee48086..7040938ab2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -72,7 +72,7 @@ const zRgbColor = z.object({ b: z.number().int().min(0).max(255), }); export type RgbColor = z.infer; -const zRgbaColor = zRgbColor.extend({ +export const zRgbaColor = zRgbColor.extend({ a: z.number().min(0).max(1), }); export type RgbaColor = z.infer; diff --git a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts index acc02785b9..3daf1f4137 100644 --- a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts +++ b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts @@ -96,7 +96,7 @@ export type ParameterModel = z.infer; // #endregion // #region SDXL Refiner Model -const zParameterSDXLRefinerModel = zModelIdentifierField; +export const zParameterSDXLRefinerModel = zModelIdentifierField; export type ParameterSDXLRefinerModel = z.infer; // #endregion @@ -188,7 +188,7 @@ export type ParameterSDXLRefinerStart = z.infer; // #endregion From a0b0c30be98238c3511fb097ae1dcab7f8993cec Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 21 May 2025 16:40:34 +1000 Subject: [PATCH 007/210] refactor(ui): move params state to big file of canvas zod stuff --- .../controlLayers/store/canvasSlice.ts | 10 +-- .../controlLayers/store/paramsSlice.ts | 89 ++----------------- .../src/features/controlLayers/store/types.ts | 75 +++++++++++++++- 3 files changed, 84 insertions(+), 90 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index cbcd0dcb26..1f49abfa87 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -72,7 +72,7 @@ import type { import { DEFAULT_ASPECT_RATIO_CONFIG, getEntityIdentifier, - getInitialState, + getInitialCanvasState, isChatGPT4oAspectRatioID, isFluxKontextAspectRatioID, isImagenAspectRatioID, @@ -97,7 +97,7 @@ import { export const canvasSlice = createSlice({ name: 'canvas', - initialState: getInitialState(), + initialState: getInitialCanvasState(), reducers: { // undoable canvas state //#region Raster layers @@ -1745,7 +1745,7 @@ export const canvasSlice = createSlice({ }, allEntitiesDeleted: (state) => { // Deleting all entities is equivalent to resetting the state for each entity type - const initialState = getInitialState(); + const initialState = getInitialCanvasState(); state.rasterLayers = initialState.rasterLayers; state.controlLayers = initialState.controlLayers; state.inpaintMasks = initialState.inpaintMasks; @@ -1809,7 +1809,7 @@ export const canvasSlice = createSlice({ }); const resetState = (state: CanvasState) => { - const newState = getInitialState(); + const newState = getInitialCanvasState(); // We need to retain the optimal dimension across resets, as it is changed only when the model changes. Copy it // from the old state, then recalculate the bbox size & scaled size. @@ -1931,7 +1931,7 @@ const migrate = (state: any): any => { export const canvasPersistConfig: PersistConfig = { name: canvasSlice.name, - initialState: getInitialState(), + initialState: getInitialCanvasState(), migrate, persistDenylist: [], }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 72060f38ad..a6a7e8d98d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -1,8 +1,8 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { deepClone } from 'common/util/deepClone'; -import { type RgbaColor, zRgbaColor } from 'features/controlLayers/store/types'; +import type { ParamsState, RgbaColor } from 'features/controlLayers/store/types'; +import { getInitialParamsState } from 'features/controlLayers/store/types'; import { CLIP_SKIP_MAP } from 'features/parameters/types/constants'; import type { ParameterCanvasCoherenceMode, @@ -20,92 +20,13 @@ import type { ParameterT5EncoderModel, ParameterVAEModel, } from 'features/parameters/types/parameterSchemas'; -import { - zParameterCanvasCoherenceMode, - zParameterCFGRescaleMultiplier, - zParameterCFGScale, - zParameterCLIPEmbedModel, - zParameterCLIPGEmbedModel, - zParameterCLIPLEmbedModel, - zParameterControlLoRAModel, - zParameterGuidance, - zParameterMaskBlurMethod, - zParameterModel, - zParameterNegativePrompt, - zParameterNegativeStylePromptSDXL, - zParameterPositivePrompt, - zParameterPositiveStylePromptSDXL, - zParameterPrecision, - zParameterScheduler, - zParameterSDXLRefinerModel, - zParameterSeed, - zParameterSteps, - zParameterStrength, - zParameterT5EncoderModel, - zParameterVAEModel, -} from 'features/parameters/types/parameterSchemas'; import { clamp } from 'lodash-es'; -import { z } from 'zod'; import { newSessionRequested } from './actions'; -const zParamsState = z.object({ - maskBlur: z.number().default(16), - maskBlurMethod: zParameterMaskBlurMethod.default('box'), - canvasCoherenceMode: zParameterCanvasCoherenceMode.default('Gaussian Blur'), - canvasCoherenceMinDenoise: zParameterStrength.default(0), - canvasCoherenceEdgeSize: z.number().default(16), - infillMethod: z.string().default('lama'), - infillTileSize: z.number().default(32), - infillPatchmatchDownscaleSize: z.number().default(1), - infillColorValue: zRgbaColor.default({ r: 0, g: 0, b: 0, a: 1 }), - cfgScale: zParameterCFGScale.default(7.5), - cfgRescaleMultiplier: zParameterCFGRescaleMultiplier.default(0), - guidance: zParameterGuidance.default(4), - img2imgStrength: zParameterStrength.default(0.75), - optimizedDenoisingEnabled: z.boolean().default(true), - iterations: z.number().default(1), - scheduler: zParameterScheduler.default('dpmpp_3m_k'), - upscaleScheduler: zParameterScheduler.default('kdpm_2'), - upscaleCfgScale: zParameterCFGScale.default(2), - seed: zParameterSeed.default(0), - shouldRandomizeSeed: z.boolean().default(true), - steps: zParameterSteps.default(30), - model: zParameterModel.nullable().default(null), - vae: zParameterVAEModel.nullable().default(null), - vaePrecision: zParameterPrecision.default('fp32'), - fluxVAE: zParameterVAEModel.nullable().default(null), - seamlessXAxis: z.boolean().default(false), - seamlessYAxis: z.boolean().default(false), - clipSkip: z.number().default(0), - shouldUseCpuNoise: z.boolean().default(true), - positivePrompt: zParameterPositivePrompt.default(''), - negativePrompt: zParameterNegativePrompt.default(''), - positivePrompt2: zParameterPositiveStylePromptSDXL.default(''), - negativePrompt2: zParameterNegativeStylePromptSDXL.default(''), - shouldConcatPrompts: z.boolean().default(true), - refinerModel: zParameterSDXLRefinerModel.nullable().default(null), - refinerSteps: z.number().default(20), - refinerCFGScale: z.number().default(7.5), - refinerScheduler: zParameterScheduler.default('euler'), - refinerPositiveAestheticScore: z.number().default(6), - refinerNegativeAestheticScore: z.number().default(2.5), - refinerStart: z.number().default(0.8), - t5EncoderModel: zParameterT5EncoderModel.nullable().default(null), - clipEmbedModel: zParameterCLIPEmbedModel.nullable().default(null), - clipLEmbedModel: zParameterCLIPLEmbedModel.nullable().default(null), - clipGEmbedModel: zParameterCLIPGEmbedModel.nullable().default(null), - controlLora: zParameterControlLoRAModel.nullable().default(null), -}); - -export type ParamsState = z.infer; - -const INITIAL_STATE = zParamsState.parse({}); -const getInitialState = () => deepClone(INITIAL_STATE); - export const paramsSlice = createSlice({ name: 'params', - initialState: getInitialState(), + initialState: getInitialParamsState(), reducers: { setIterations: (state, action: PayloadAction) => { state.iterations = action.payload; @@ -273,7 +194,7 @@ export const paramsSlice = createSlice({ const resetState = (state: ParamsState): ParamsState => { // When a new session is requested, we need to keep the current model selections, plus dependent state // like VAE precision. Everything else gets reset to default. - const newState = getInitialState(); + const newState = getInitialParamsState(); newState.model = state.model; newState.vae = state.vae; newState.fluxVAE = state.fluxVAE; @@ -339,7 +260,7 @@ const migrate = (state: any): any => { export const paramsPersistConfig: PersistConfig = { name: paramsSlice.name, - initialState: getInitialState(), + initialState: getInitialParamsState(), migrate, persistDenylist: [], }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 7040938ab2..fca9e1a6ce 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -4,9 +4,29 @@ import { fetchModelConfigByIdentifier } from 'features/metadata/util/modelFetchi import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import type { ParameterLoRAModel } from 'features/parameters/types/parameterSchemas'; import { + zParameterCanvasCoherenceMode, + zParameterCFGRescaleMultiplier, + zParameterCFGScale, + zParameterCLIPEmbedModel, + zParameterCLIPGEmbedModel, + zParameterCLIPLEmbedModel, + zParameterControlLoRAModel, + zParameterGuidance, zParameterImageDimension, + zParameterMaskBlurMethod, + zParameterModel, zParameterNegativePrompt, + zParameterNegativeStylePromptSDXL, zParameterPositivePrompt, + zParameterPositiveStylePromptSDXL, + zParameterPrecision, + zParameterScheduler, + zParameterSDXLRefinerModel, + zParameterSeed, + zParameterSteps, + zParameterStrength, + zParameterT5EncoderModel, + zParameterVAEModel, } from 'features/parameters/types/parameterSchemas'; import { getImageDTOSafe } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; @@ -467,6 +487,59 @@ const zBboxState = z.object({ scaleMethod: zBoundingBoxScaleMethod, modelBase: zMainModelBase, }); + +const zParamsState = z.object({ + maskBlur: z.number().default(16), + maskBlurMethod: zParameterMaskBlurMethod.default('box'), + canvasCoherenceMode: zParameterCanvasCoherenceMode.default('Gaussian Blur'), + canvasCoherenceMinDenoise: zParameterStrength.default(0), + canvasCoherenceEdgeSize: z.number().default(16), + infillMethod: z.string().default('lama'), + infillTileSize: z.number().default(32), + infillPatchmatchDownscaleSize: z.number().default(1), + infillColorValue: zRgbaColor.default({ r: 0, g: 0, b: 0, a: 1 }), + cfgScale: zParameterCFGScale.default(7.5), + cfgRescaleMultiplier: zParameterCFGRescaleMultiplier.default(0), + guidance: zParameterGuidance.default(4), + img2imgStrength: zParameterStrength.default(0.75), + optimizedDenoisingEnabled: z.boolean().default(true), + iterations: z.number().default(1), + scheduler: zParameterScheduler.default('dpmpp_3m_k'), + upscaleScheduler: zParameterScheduler.default('kdpm_2'), + upscaleCfgScale: zParameterCFGScale.default(2), + seed: zParameterSeed.default(0), + shouldRandomizeSeed: z.boolean().default(true), + steps: zParameterSteps.default(30), + model: zParameterModel.nullable().default(null), + vae: zParameterVAEModel.nullable().default(null), + vaePrecision: zParameterPrecision.default('fp32'), + fluxVAE: zParameterVAEModel.nullable().default(null), + seamlessXAxis: z.boolean().default(false), + seamlessYAxis: z.boolean().default(false), + clipSkip: z.number().default(0), + shouldUseCpuNoise: z.boolean().default(true), + positivePrompt: zParameterPositivePrompt.default(''), + negativePrompt: zParameterNegativePrompt.default(''), + positivePrompt2: zParameterPositiveStylePromptSDXL.default(''), + negativePrompt2: zParameterNegativeStylePromptSDXL.default(''), + shouldConcatPrompts: z.boolean().default(true), + refinerModel: zParameterSDXLRefinerModel.nullable().default(null), + refinerSteps: z.number().default(20), + refinerCFGScale: z.number().default(7.5), + refinerScheduler: zParameterScheduler.default('euler'), + refinerPositiveAestheticScore: z.number().default(6), + refinerNegativeAestheticScore: z.number().default(2.5), + refinerStart: z.number().default(0.8), + t5EncoderModel: zParameterT5EncoderModel.nullable().default(null), + clipEmbedModel: zParameterCLIPEmbedModel.nullable().default(null), + clipLEmbedModel: zParameterCLIPLEmbedModel.nullable().default(null), + clipGEmbedModel: zParameterCLIPGEmbedModel.nullable().default(null), + controlLora: zParameterControlLoRAModel.nullable().default(null), +}); +export type ParamsState = z.infer; +const INITIAL_PARAMS_STATE = zParamsState.parse({}); +export const getInitialParamsState = () => deepClone(INITIAL_PARAMS_STATE); + const zCanvasState = z.object({ _version: z.literal(3).default(3), isSessionStarted: z.boolean().default(false), @@ -518,7 +591,7 @@ export type CanvasState = z.infer; * Gets a fresh canvas initial state with no references in memory to existing objects. */ const CANVAS_INITIAL_STATE = zCanvasState.parse({}); -export const getInitialState = () => deepClone(CANVAS_INITIAL_STATE); +export const getInitialCanvasState = () => deepClone(CANVAS_INITIAL_STATE); export const zCanvasMetadata = z.object({ inpaintMasks: z.array(zCanvasInpaintMaskState), From 02e4a3aa82e8862d5be277cf53a50dd3b548801a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 22 May 2025 13:54:57 +1000 Subject: [PATCH 008/210] refactor(ui): params state zodification --- .../components/CanvasMainPanelContent.tsx | 30 ++++++++-- .../src/features/controlLayers/store/types.ts | 58 ++++++++----------- .../util/getScaledBoundingBoxDimensions.ts | 18 +++--- .../nodes/util/graph/graphBuilderUtils.ts | 4 +- .../parameters/util/optimalDimension.ts | 5 +- .../web/src/features/queue/store/readiness.ts | 3 +- 6 files changed, 65 insertions(+), 53 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 9ea0026b59..4bc6262c41 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -26,6 +26,7 @@ import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasT import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; +import { selectIsSessionStarted } from 'features/controlLayers/store/selectors'; import { memo, useCallback } from 'react'; import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; @@ -36,7 +37,7 @@ const FOCUS_REGION_STYLES: SystemStyleObject = { height: 'full', }; -const MenuContent = () => { +const MenuContent = memo(() => { return ( @@ -45,9 +46,31 @@ const MenuContent = () => { ); -}; +}); +MenuContent.displayName = 'MenuContent'; export const CanvasMainPanelContent = memo(() => { + const isSessionStarted = useAppSelector(selectIsSessionStarted); + + if (!isSessionStarted) { + return ; + } + + return ; +}); + +CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; + +const CanvasNoSession = memo(() => { + return ( + + FRESH CANVAS is fresh when: - No control layers - No inpaint masks - No regions - No Raster Layers + + ); +}); +CanvasNoSession.displayName = 'CanvasNoSession'; + +const CanvasActiveSession = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); @@ -134,5 +157,4 @@ export const CanvasMainPanelContent = memo(() => { ); }); - -CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; +CanvasActiveSession.displayName = 'ActiveCanvasContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index fca9e1a6ce..09b540f951 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -540,48 +540,40 @@ export type ParamsState = z.infer; const INITIAL_PARAMS_STATE = zParamsState.parse({}); export const getInitialParamsState = () => deepClone(INITIAL_PARAMS_STATE); +const zInpaintMasks = z.object({ + isHidden: z.boolean(), + entities: z.array(zCanvasInpaintMaskState), +}); +const zRasterLayers = z.object({ + isHidden: z.boolean(), + entities: z.array(zCanvasRasterLayerState), +}); +const zControlLayers = z.object({ + isHidden: z.boolean(), + entities: z.array(zCanvasControlLayerState), +}); +const zRegionalGuidance = z.object({ + isHidden: z.boolean(), + entities: z.array(zCanvasRegionalGuidanceState), +}); +const zReferenceImages = z.object({ + entities: z.array(zCanvasReferenceImageState), +}); const zCanvasState = z.object({ _version: z.literal(3).default(3), isSessionStarted: z.boolean().default(false), selectedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null), bookmarkedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null), - inpaintMasks: z - .object({ - isHidden: z.boolean(), - entities: z.array(zCanvasInpaintMaskState), - }) - .default({ isHidden: false, entities: [] }), - rasterLayers: z - .object({ - isHidden: z.boolean(), - entities: z.array(zCanvasRasterLayerState), - }) - .default({ isHidden: false, entities: [] }), - controlLayers: z - .object({ - isHidden: z.boolean(), - entities: z.array(zCanvasControlLayerState), - }) - .default({ isHidden: false, entities: [] }), - regionalGuidance: z - .object({ - isHidden: z.boolean(), - entities: z.array(zCanvasRegionalGuidanceState), - }) - .default({ isHidden: false, entities: [] }), - referenceImages: z - .object({ - entities: z.array(zCanvasReferenceImageState), - }) - .default({ entities: [] }), + inpaintMasks: zInpaintMasks.default({ isHidden: false, entities: [] }), + rasterLayers: zRasterLayers.default({ isHidden: false, entities: [] }), + controlLayers: zControlLayers.default({ isHidden: false, entities: [] }), + regionalGuidance: zRegionalGuidance.default({ isHidden: false, entities: [] }), + referenceImages: zReferenceImages.default({ entities: [] }), bbox: zBboxState.default({ rect: { x: 0, y: 0, width: 512, height: 512 }, aspectRatio: DEFAULT_ASPECT_RATIO_CONFIG, scaleMethod: 'auto', - scaledSize: { - width: 512, - height: 512, - }, + scaledSize: { width: 512, height: 512 }, modelBase: 'sd-1', }), }); diff --git a/invokeai/frontend/web/src/features/controlLayers/util/getScaledBoundingBoxDimensions.ts b/invokeai/frontend/web/src/features/controlLayers/util/getScaledBoundingBoxDimensions.ts index 3b12790f2f..5f58e77545 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/getScaledBoundingBoxDimensions.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/getScaledBoundingBoxDimensions.ts @@ -1,6 +1,6 @@ import { roundToMultiple } from 'common/util/roundDownToMultiple'; import type { Dimensions } from 'features/controlLayers/store/types'; -import type { MainModelBase } from 'features/nodes/types/common'; +import type { BaseModelType } from 'features/nodes/types/common'; import { getGridSize, getOptimalDimension, @@ -11,16 +11,16 @@ import { * Scales the bounding box dimensions to the optimal dimension. The optimal dimensions should be the trained dimension * for the model. For example, 1024 for SDXL or 512 for SD1.5. * @param dimensions The un-scaled bbox dimensions - * @param modelBase The base model + * @param base The base model */ -export const getScaledBoundingBoxDimensions = (dimensions: Dimensions, modelBase: MainModelBase): Dimensions => { +export const getScaledBoundingBoxDimensions = (dimensions: Dimensions, base?: BaseModelType): Dimensions => { // Special cases: Return original if SDXL and in training dimensions - if (modelBase === 'sdxl' && isInSDXLTrainingDimensions(dimensions.width, dimensions.height)) { + if (base === 'sdxl' && isInSDXLTrainingDimensions(dimensions.width, dimensions.height)) { return { ...dimensions }; } - const optimalDimension = getOptimalDimension(modelBase); - const gridSize = getGridSize(modelBase); + const optimalDimension = getOptimalDimension(base); + const gridSize = getGridSize(base); const width = roundToMultiple(dimensions.width, gridSize); const height = roundToMultiple(dimensions.height, gridSize); @@ -56,13 +56,13 @@ export const getScaledBoundingBoxDimensions = (dimensions: Dimensions, modelBase * Calculate the new width and height that will fit the given aspect ratio, retaining the input area * @param ratio The aspect ratio to calculate the new size for * @param area The input area - * @param modelBase The base model + * @param base The base model * @returns The width and height that will fit the given aspect ratio, retaining the input area */ -export const calculateNewSize = (ratio: number, area: number, modelBase: MainModelBase): Dimensions => { +export const calculateNewSize = (ratio: number, area: number, base?: BaseModelType): Dimensions => { const exactWidth = Math.sqrt(area * ratio); const exactHeight = exactWidth / ratio; - const gridSize = getGridSize(modelBase); + const gridSize = getGridSize(base); return { width: roundToMultiple(exactWidth, gridSize), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts index c288f80bb7..3ebe337ed0 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; -import { type ParamsState, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; -import type { CanvasState } from 'features/controlLayers/store/types'; +import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import type { CanvasState, ParamsState } from 'features/controlLayers/store/types'; import type { BoardField } from 'features/nodes/types/common'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import { buildPresetModifiedPrompt } from 'features/stylePresets/hooks/usePresetModifiedPrompts'; diff --git a/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts b/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts index 90a5b41348..9041a87213 100644 --- a/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts +++ b/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts @@ -1,4 +1,3 @@ -import type { MainModelBase } from 'features/nodes/types/common'; import type { BaseModelType } from 'services/api/types'; /** @@ -117,7 +116,7 @@ export const getIsSizeTooLarge = (width: number, height: number, optimalDimensio * @param optimalDimension The optimal dimension * @returns Whether the current width and height needs to be resized to the optimal dimension */ -export const getIsSizeOptimal = (width: number, height: number, modelBase: MainModelBase): boolean => { - const optimalDimension = getOptimalDimension(modelBase); +export const getIsSizeOptimal = (width: number, height: number, base?: BaseModelType): boolean => { + const optimalDimension = getOptimalDimension(base); return !getIsSizeTooSmall(width, height, optimalDimension) && !getIsSizeTooLarge(width, height, optimalDimension); }; diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index a91ccbfb19..849ed380e7 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -8,10 +8,9 @@ import { useAppSelector } from 'app/store/storeHooks'; import type { AppConfig } from 'app/types/invokeai'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import type { ParamsState } from 'features/controlLayers/store/paramsSlice'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { CanvasState } from 'features/controlLayers/store/types'; +import type { CanvasState, ParamsState } from 'features/controlLayers/store/types'; import { getControlLayerWarnings, getGlobalReferenceImageWarnings, From c4d1e78f59e1d18e0ca068255d00e5b4e45e8fd3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 22 May 2025 14:18:02 +1000 Subject: [PATCH 009/210] fix(ui): circular import issue --- .../components/CanvasMainPanelContent.tsx | 5 ++-- .../common/CanvasEntityHeaderWarnings.tsx | 2 +- .../controlLayers/hooks/addLayerHooks.ts | 2 +- .../controlLayers/hooks/saveCanvasHooks.ts | 2 +- .../controlLayers/store/paramsSlice.ts | 23 +++++++++++++++++ .../graph/generation/buildChatGPT4oGraph.ts | 2 +- .../util/graph/generation/buildFLUXGraph.ts | 2 +- .../graph/generation/buildImagen3Graph.ts | 2 +- .../util/graph/generation/buildSD1Graph.ts | 2 +- .../util/graph/generation/buildSD3Graph.ts | 2 +- .../util/graph/generation/buildSDXLGraph.ts | 2 +- .../web/src/features/queue/store/readiness.ts | 3 +-- .../web/src/services/api/endpoints/models.ts | 25 ++----------------- 13 files changed, 38 insertions(+), 36 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 4bc6262c41..18c9f54489 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -26,7 +26,7 @@ import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasT import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectIsSessionStarted } from 'features/controlLayers/store/selectors'; +import { selectIsCanvasEmpty, selectIsSessionStarted } from 'features/controlLayers/store/selectors'; import { memo, useCallback } from 'react'; import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; @@ -51,8 +51,9 @@ MenuContent.displayName = 'MenuContent'; export const CanvasMainPanelContent = memo(() => { const isSessionStarted = useAppSelector(selectIsSessionStarted); + const isCanvasEmpty = useAppSelector(selectIsCanvasEmpty); - if (!isSessionStarted) { + if (!isSessionStarted && isCanvasEmpty) { return ; } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx index ea61e9e30a..b3f0e21972 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx @@ -18,7 +18,7 @@ import { upperFirst } from 'lodash-es'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiWarningBold } from 'react-icons/pi'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts index 7415678b71..dfc4780d37 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -37,9 +37,9 @@ import { zModelIdentifierField } from 'features/nodes/types/common'; import { useCallback } from 'react'; import { modelConfigsAdapterSelectors, - selectMainModelConfig, selectModelConfigsQuery, } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from '../store/paramsSlice'; import type { ControlLoRAModelConfig, ControlNetModelConfig, diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts index 06f4a0328e..5eb153cfc7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts @@ -33,7 +33,7 @@ import { toast } from 'features/toast/toast'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { serializeError } from 'serialize-error'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from '../store/paramsSlice'; import type { ImageDTO } from 'services/api/types'; import type { JsonObject } from 'type-fest'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index a6a7e8d98d..dabddf3c13 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -21,6 +21,8 @@ import type { ParameterVAEModel, } from 'features/parameters/types/parameterSchemas'; import { clamp } from 'lodash-es'; +import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; +import { isNonRefinerMainModelConfig } from 'services/api/types'; import { newSessionRequested } from './actions'; @@ -336,3 +338,24 @@ export const selectRefinerNegativeAestheticScore = createParamsSelector( export const selectRefinerScheduler = createParamsSelector((params) => params.refinerScheduler); export const selectRefinerStart = createParamsSelector((params) => params.refinerStart); export const selectRefinerSteps = createParamsSelector((params) => params.refinerSteps); + +export const selectMainModelConfig = createSelector( + selectModelConfigsQuery, + selectParamsSlice, + (modelConfigs, { model }) => { + if (!modelConfigs.data) { + return null; + } + if (!model) { + return null; + } + const modelConfig = modelConfigsAdapterSelectors.selectById(modelConfigs.data, model.key); + if (!modelConfig) { + return null; + } + if (!isNonRefinerMainModelConfig(modelConfig)) { + return null; + } + return modelConfig; + } +); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index 0a93900d90..95f1a20cf3 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -15,7 +15,7 @@ import { } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts index 12379ced19..bff3803fb3 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts @@ -28,7 +28,7 @@ import { UnsupportedGenerationModeError, } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts index 7f573bdecf..7be80eb788 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts @@ -14,7 +14,7 @@ import { } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index 8092c5ecd4..73875d57b8 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -24,7 +24,7 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts index 523ddb0e80..c0c864daaa 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts @@ -19,7 +19,7 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index ee00ce320b..f6c167770e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -24,7 +24,7 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index 849ed380e7..ebe96a21be 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -8,7 +8,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import type { AppConfig } from 'app/types/invokeai'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectMainModelConfig,selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import type { CanvasState, ParamsState } from 'features/controlLayers/store/types'; import { @@ -42,7 +42,6 @@ import i18n from 'i18next'; import { debounce, groupBy, upperFirst } from 'lodash-es'; import { atom, computed } from 'nanostores'; import { useEffect } from 'react'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; import type { MainModelConfig } from 'services/api/types'; import { $isConnected } from 'services/events/stores'; diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index aa78e8f739..405c794582 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -1,7 +1,6 @@ import type { EntityState } from '@reduxjs/toolkit'; -import { createEntityAdapter, createSelector } from '@reduxjs/toolkit'; +import { createEntityAdapter } from '@reduxjs/toolkit'; import { getSelectorsOptions } from 'app/store/createMemoizedSelector'; -import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import queryString from 'query-string'; import type { operations, paths } from 'services/api/schema'; import type { @@ -11,7 +10,6 @@ import type { SetHFTokenArg, SetHFTokenResponse, } from 'services/api/types'; -import { isNonRefinerMainModelConfig } from 'services/api/types'; import type { Param0 } from 'tsafe'; import type { ApiTagDescription } from '..'; @@ -326,23 +324,4 @@ export const { } = modelsApi; export const selectModelConfigsQuery = modelsApi.endpoints.getModelConfigs.select(); -export const selectMainModelConfig = createSelector( - selectModelConfigsQuery, - selectParamsSlice, - (modelConfigs, { model }) => { - if (!modelConfigs.data) { - return null; - } - if (!model) { - return null; - } - const modelConfig = modelConfigsAdapterSelectors.selectById(modelConfigs.data, model.key); - if (!modelConfig) { - return null; - } - if (!isNonRefinerMainModelConfig(modelConfig)) { - return null; - } - return modelConfig; - } -); + From cf2d67ef3d17decc4ebae83b2e4e5e74292f0412 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 22 May 2025 17:41:19 +1000 Subject: [PATCH 010/210] refactor(ui): canvas flow (wip) --- .../listeners/enqueueRequestedLinear.ts | 6 +- .../components/CanvasMainPanelContent.tsx | 83 ++++++++++--- .../controlLayers/store/canvasSlice.ts | 13 +- .../store/canvasStagingAreaSlice.ts | 4 + .../graph/generation/buildChatGPT4oGraph.ts | 11 +- .../graph/generation/buildCogView4Graph.ts | 11 +- .../util/graph/generation/buildFLUXGraph.ts | 102 +++++++++------- .../graph/generation/buildImagen3Graph.ts | 10 +- .../graph/generation/buildImagen4Graph.ts | 10 +- .../util/graph/generation/buildSD1Graph.ts | 112 ++++++++++-------- .../util/graph/generation/buildSD3Graph.ts | 11 +- .../util/graph/generation/buildSDXLGraph.ts | 111 +++++++++-------- .../graph/generation/getGenerationMode.ts | 9 ++ .../web/src/features/queue/store/readiness.ts | 14 +-- 14 files changed, 315 insertions(+), 192 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts index afd9a2fff2..8ed57f33ad 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -35,7 +35,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) const { prepend } = action.payload; const manager = $canvasManager.get(); - assert(manager, 'No canvas manager'); + // assert(manager, 'No canvas manager'); const model = state.params.model; assert(model, 'No model found in state'); @@ -90,7 +90,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) const { g, seedFieldIdentifier, positivePromptFieldIdentifier } = buildGraphResult.value; - const destination = state.canvasSettings.sendToCanvas ? 'canvas' : 'gallery'; + // const destination = state.canvasSettings.sendToCanvas ? 'canvas' : 'gallery'; const prepareBatchResult = withResult(() => prepareLinearUIBatch({ @@ -100,7 +100,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) seedFieldIdentifier, positivePromptFieldIdentifier, origin: 'canvas', - destination, + destination: 'canvas', }) ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 18c9f54489..2861a1db54 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,13 +1,6 @@ -import { - ContextMenu, - Flex, - IconButton, - Menu, - MenuButton, - MenuList, - type SystemStyleObject, -} from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Button, ContextMenu, Flex, IconButton, Image, Menu, MenuButton, MenuList, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; @@ -25,7 +18,13 @@ import { StagingAreaToolbar } from 'features/controlLayers/components/StagingAre import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { newCanvasSessionRequested } from 'features/controlLayers/store/actions'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; +import { + selectIsStaging, + selectSelectedImage, + selectStagedImages, +} from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectIsCanvasEmpty, selectIsSessionStarted } from 'features/controlLayers/store/selectors'; import { memo, useCallback } from 'react'; import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; @@ -54,7 +53,11 @@ export const CanvasMainPanelContent = memo(() => { const isCanvasEmpty = useAppSelector(selectIsCanvasEmpty); if (!isSessionStarted && isCanvasEmpty) { - return ; + return ; + } + + if (isSessionStarted && isCanvasEmpty) { + return ; } return ; @@ -62,14 +65,64 @@ export const CanvasMainPanelContent = memo(() => { CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; -const CanvasNoSession = memo(() => { +const NoActiveSession = memo(() => { + const dispatch = useAppDispatch(); + const newSesh = useCallback(() => { + dispatch(newCanvasSessionRequested()); + }, [dispatch]); return ( - - FRESH CANVAS is fresh when: - No control layers - No inpaint masks - No regions - No Raster Layers + + + No Active Session + + + + Generate with Starting Image + - New Canvas Session + - Dropped image as raster layer + - Bbox resized + + + Generate with Control Image + - New Canvas Session + - Dropped image as control layer + - Bbox resized + + + Edit Image + - New Canvas Session + - Dropped image as raster layer + - Bbox resized + - 1 Inpaint mask layer added + ); }); -CanvasNoSession.displayName = 'CanvasNoSession'; +NoActiveSession.displayName = 'NoActiveSession'; + +const SimpleActiveSession = memo(() => { + const isStaging = useAppSelector(selectIsStaging); + const selectedImage = useAppSelector(selectSelectedImage); + const stagedImages = useAppSelector(selectStagedImages); + return ( + + + Simple Session (staging view) {isStaging && 'STAGING'} + + {selectedImage && } + + {stagedImages.map(({ imageDTO }) => ( + + ))} + + + ); +}); +SimpleActiveSession.displayName = 'SimpleActiveSession'; const CanvasActiveSession = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 1f49abfa87..d02aaed004 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -5,7 +5,11 @@ import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/uti import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { canvasReset, newSessionRequested } from 'features/controlLayers/store/actions'; +import { + canvasReset, + newCanvasSessionRequested, + newGallerySessionRequested, +} from 'features/controlLayers/store/actions'; import { modelChanged } from 'features/controlLayers/store/paramsSlice'; import { selectAllEntities, @@ -1802,9 +1806,14 @@ export const canvasSlice = createSlice({ syncScaledSize(state); } }); - builder.addMatcher(newSessionRequested, (state) => { + builder.addCase(newGallerySessionRequested, (state) => { return resetState(state); }); + builder.addCase(newCanvasSessionRequested, (state) => { + const newState = resetState(state); + newState.isSessionStarted = true; + return newState; + }); }, }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index bf37b6e0d1..660ac1a680 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -97,6 +97,10 @@ export const selectSelectedImage = createSelector( [selectCanvasStagingAreaSlice, selectStagedImageIndex], (stagingArea, index) => stagingArea.stagedImages[index] ?? null ); +export const selectStagedImages = createSelector( + selectCanvasStagingAreaSlice, + (stagingArea) => stagingArea.stagedImages +); export const selectImageCount = createSelector( selectCanvasStagingAreaSlice, (stagingArea) => stagingArea.stagedImages.length diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index 95f1a20cf3..c53da0e1ec 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -3,10 +3,12 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isChatGPT4oAspectRatioID, isChatGPT4oReferenceImageConfig } from 'features/controlLayers/store/types'; import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators'; import { type ImageField, zModelIdentifierField } from 'features/nodes/types/common'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -15,14 +17,16 @@ import { } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; -import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; const log = logger('system'); -export const buildChatGPT4oGraph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildChatGPT4oGraph = async ( + state: RootState, + manager?: CanvasManager | null +): Promise => { + const generationMode = await getGenerationMode(manager); if (generationMode !== 'txt2img' && generationMode !== 'img2img') { throw new UnsupportedGenerationModeError(t('toast.chatGPT4oIncompatibleGenerationMode')); @@ -91,6 +95,7 @@ export const buildChatGPT4oGraph = async (state: RootState, manager: CanvasManag } if (generationMode === 'img2img') { + assert(manager, 'Need manager to do img2img'); const adapters = manager.compositor.getVisibleAdaptersOfType('raster_layer'); const { image_name } = await manager.compositor.getCompositeImageDTO(adapters, bbox.rect, { is_intermediate: true, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts index 4f19c6bd2e..b0b29774e9 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts @@ -12,6 +12,7 @@ import { addNSFWChecker } from 'features/nodes/util/graph/generation/addNSFWChec import { addOutpaint } from 'features/nodes/util/graph/generation/addOutpaint'; import { addTextToImage } from 'features/nodes/util/graph/generation/addTextToImage'; import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -27,8 +28,11 @@ import { assert } from 'tsafe'; const log = logger('system'); -export const buildCogView4Graph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildCogView4Graph = async ( + state: RootState, + manager?: CanvasManager | null +): Promise => { + const generationMode = await getGenerationMode(manager); log.debug({ generationMode }, 'Building CogView4 graph'); const params = selectParamsSlice(state); @@ -109,6 +113,7 @@ export const buildCogView4Graph = async (state: RootState, manager: CanvasManage canvasOutput = addTextToImage({ g, l2i, originalSize, scaledSize }); g.upsertMetadata({ generation_mode: 'cogview4_txt2img' }); } else if (generationMode === 'img2img') { + assert(manager, 'Need manager to do img2img'); canvasOutput = await addImageToImage({ g, manager, @@ -124,6 +129,7 @@ export const buildCogView4Graph = async (state: RootState, manager: CanvasManage }); g.upsertMetadata({ generation_mode: 'cogview4_img2img' }); } else if (generationMode === 'inpaint') { + assert(manager, 'Need manager to do inpaint'); canvasOutput = await addInpaint({ state, g, @@ -141,6 +147,7 @@ export const buildCogView4Graph = async (state: RootState, manager: CanvasManage }); g.upsertMetadata({ generation_mode: 'cogview4_inpaint' }); } else if (generationMode === 'outpaint') { + assert(manager, 'Need manager to do outpaint'); canvasOutput = await addOutpaint({ state, g, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts index bff3803fb3..36abfb6a4c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts @@ -3,7 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addFLUXFill } from 'features/nodes/util/graph/generation/addFLUXFill'; import { addFLUXLoRAs } from 'features/nodes/util/graph/generation/addFLUXLoRAs'; @@ -15,6 +15,7 @@ import { addOutpaint } from 'features/nodes/util/graph/generation/addOutpaint'; import { addRegions } from 'features/nodes/util/graph/generation/addRegions'; import { addTextToImage } from 'features/nodes/util/graph/generation/addTextToImage'; import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -28,7 +29,6 @@ import { UnsupportedGenerationModeError, } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; -import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -38,8 +38,8 @@ import { addIPAdapters } from './addIPAdapters'; const log = logger('system'); -export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | null): Promise => { + const generationMode = await getGenerationMode(manager); log.debug({ generationMode }, 'Building FLUX graph'); const params = selectParamsSlice(state); @@ -171,6 +171,7 @@ export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): let canvasOutput: Invocation = l2i; if (isFLUXFill) { + assert(manager, 'Need manager to do FLUX Fill'); canvasOutput = await addFLUXFill({ state, g, @@ -184,6 +185,7 @@ export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): canvasOutput = addTextToImage({ g, l2i, originalSize, scaledSize }); g.upsertMetadata({ generation_mode: 'flux_txt2img' }); } else if (generationMode === 'img2img') { + assert(manager, 'Need manager to do img2img'); canvasOutput = await addImageToImage({ g, manager, @@ -199,6 +201,7 @@ export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): }); g.upsertMetadata({ generation_mode: 'flux_img2img' }); } else if (generationMode === 'inpaint') { + assert(manager, 'Need manager to do inpaint'); canvasOutput = await addInpaint({ state, g, @@ -216,6 +219,7 @@ export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): }); g.upsertMetadata({ generation_mode: 'flux_inpaint' }); } else if (generationMode === 'outpaint') { + assert(manager, 'Need manager to do outpaint'); canvasOutput = await addOutpaint({ state, g, @@ -236,32 +240,34 @@ export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): assert>(false); } - const controlNetCollector = g.addNode({ - type: 'collect', - id: getPrefixedId('control_net_collector'), - }); - const controlNetResult = await addControlNets({ - manager, - entities: canvas.controlLayers.entities, - g, - rect: canvas.bbox.rect, - collector: controlNetCollector, - model, - }); - if (controlNetResult.addedControlNets > 0) { - g.addEdge(controlNetCollector, 'collection', denoise, 'control'); - } else { - g.deleteNode(controlNetCollector.id); - } + if (manager) { + const controlNetCollector = g.addNode({ + type: 'collect', + id: getPrefixedId('control_net_collector'), + }); + const controlNetResult = await addControlNets({ + manager, + entities: canvas.controlLayers.entities, + g, + rect: canvas.bbox.rect, + collector: controlNetCollector, + model, + }); + if (controlNetResult.addedControlNets > 0) { + g.addEdge(controlNetCollector, 'collection', denoise, 'control'); + } else { + g.deleteNode(controlNetCollector.id); + } - await addControlLoRA({ - manager, - entities: canvas.controlLayers.entities, - g, - rect: canvas.bbox.rect, - denoise, - model, - }); + await addControlLoRA({ + manager, + entities: canvas.controlLayers.entities, + g, + rect: canvas.bbox.rect, + denoise, + model, + }); + } const ipAdapterCollect = g.addNode({ type: 'collect', @@ -274,6 +280,8 @@ export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): model, }); + let totalIPAdaptersAdded = ipAdapterResult.addedIPAdapters; + const fluxReduxCollect = g.addNode({ type: 'collect', id: getPrefixedId('ip_adapter_collector'), @@ -284,31 +292,33 @@ export const buildFLUXGraph = async (state: RootState, manager: CanvasManager): collector: fluxReduxCollect, model, }); + let totalReduxesAdded = fluxReduxResult.addedFLUXReduxes; - const regionsResult = await addRegions({ - manager, - regions: canvas.regionalGuidance.entities, - g, - bbox: canvas.bbox.rect, - model, - posCond, - negCond: null, - posCondCollect, - negCondCollect: null, - ipAdapterCollect, - fluxReduxCollect, - }); + if (manager) { + const regionsResult = await addRegions({ + manager, + regions: canvas.regionalGuidance.entities, + g, + bbox: canvas.bbox.rect, + model, + posCond, + negCond: null, + posCondCollect, + negCondCollect: null, + ipAdapterCollect, + fluxReduxCollect, + }); + + totalIPAdaptersAdded += regionsResult.reduce((acc, r) => acc + r.addedIPAdapters, 0); + totalReduxesAdded += regionsResult.reduce((acc, r) => acc + r.addedFLUXReduxes, 0); + } - const totalIPAdaptersAdded = - ipAdapterResult.addedIPAdapters + regionsResult.reduce((acc, r) => acc + r.addedIPAdapters, 0); if (totalIPAdaptersAdded > 0) { g.addEdge(ipAdapterCollect, 'collection', denoise, 'ip_adapter'); } else { g.deleteNode(ipAdapterCollect.id); } - const totalReduxesAdded = - fluxReduxResult.addedFLUXReduxes + regionsResult.reduce((acc, r) => acc + r.addedFLUXReduxes, 0); if (totalReduxesAdded > 0) { g.addEdge(fluxReduxCollect, 'collection', denoise, 'redux_conditioning'); } else { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts index 7be80eb788..6624aa73af 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts @@ -3,9 +3,11 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isImagenAspectRatioID } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -14,14 +16,16 @@ import { } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; -import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; const log = logger('system'); -export const buildImagen3Graph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildImagen3Graph = async ( + state: RootState, + manager?: CanvasManager | null +): Promise => { + const generationMode = await getGenerationMode(manager); if (generationMode !== 'txt2img') { throw new UnsupportedGenerationModeError(t('toast.imagenIncompatibleGenerationMode', { model: 'Imagen3' })); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts index 71ea35ff01..698552be54 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts @@ -3,9 +3,11 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isImagenAspectRatioID } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -14,14 +16,16 @@ import { } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; -import { selectMainModelConfig } from 'services/api/endpoints/models'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; const log = logger('system'); -export const buildImagen4Graph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildImagen4Graph = async ( + state: RootState, + manager?: CanvasManager | null +): Promise => { + const generationMode = await getGenerationMode(manager); if (generationMode !== 'txt2img') { throw new UnsupportedGenerationModeError(t('toast.imagenIncompatibleGenerationMode', { model: 'Imagen4' })); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index 73875d57b8..0abdadfaba 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -3,7 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addControlNets, addT2IAdapters } from 'features/nodes/util/graph/generation/addControlAdapters'; import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage'; @@ -16,6 +16,7 @@ import { addOutpaint } from 'features/nodes/util/graph/generation/addOutpaint'; import { addSeamless } from 'features/nodes/util/graph/generation/addSeamless'; import { addTextToImage } from 'features/nodes/util/graph/generation/addTextToImage'; import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -24,7 +25,6 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; -import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -33,8 +33,8 @@ import { addRegions } from './addRegions'; const log = logger('system'); -export const buildSD1Graph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | null): Promise => { + const generationMode = await getGenerationMode(manager); log.debug({ generationMode }, 'Building SD1/SD2 graph'); const params = selectParamsSlice(state); @@ -170,6 +170,7 @@ export const buildSD1Graph = async (state: RootState, manager: CanvasManager): P canvasOutput = addTextToImage({ g, l2i, originalSize, scaledSize }); g.upsertMetadata({ generation_mode: 'txt2img' }); } else if (generationMode === 'img2img') { + assert(manager, 'Need manager to do img2img'); canvasOutput = await addImageToImage({ g, manager, @@ -185,6 +186,7 @@ export const buildSD1Graph = async (state: RootState, manager: CanvasManager): P }); g.upsertMetadata({ generation_mode: 'img2img' }); } else if (generationMode === 'inpaint') { + assert(manager, 'Need manager to do inpaint'); canvasOutput = await addInpaint({ state, g, @@ -202,6 +204,7 @@ export const buildSD1Graph = async (state: RootState, manager: CanvasManager): P }); g.upsertMetadata({ generation_mode: 'inpaint' }); } else if (generationMode === 'outpaint') { + assert(manager, 'Need manager to do outpaint'); canvasOutput = await addOutpaint({ state, g, @@ -222,40 +225,42 @@ export const buildSD1Graph = async (state: RootState, manager: CanvasManager): P assert>(false); } - const controlNetCollector = g.addNode({ - type: 'collect', - id: getPrefixedId('control_net_collector'), - }); - const controlNetResult = await addControlNets({ - manager, - entities: canvas.controlLayers.entities, - g, - rect: canvas.bbox.rect, - collector: controlNetCollector, - model, - }); - if (controlNetResult.addedControlNets > 0) { - g.addEdge(controlNetCollector, 'collection', denoise, 'control'); - } else { - g.deleteNode(controlNetCollector.id); - } + if (manager) { + const controlNetCollector = g.addNode({ + type: 'collect', + id: getPrefixedId('control_net_collector'), + }); + const controlNetResult = await addControlNets({ + manager, + entities: canvas.controlLayers.entities, + g, + rect: canvas.bbox.rect, + collector: controlNetCollector, + model, + }); + if (controlNetResult.addedControlNets > 0) { + g.addEdge(controlNetCollector, 'collection', denoise, 'control'); + } else { + g.deleteNode(controlNetCollector.id); + } - const t2iAdapterCollector = g.addNode({ - type: 'collect', - id: getPrefixedId('t2i_adapter_collector'), - }); - const t2iAdapterResult = await addT2IAdapters({ - manager, - entities: canvas.controlLayers.entities, - g, - rect: canvas.bbox.rect, - collector: t2iAdapterCollector, - model, - }); - if (t2iAdapterResult.addedT2IAdapters > 0) { - g.addEdge(t2iAdapterCollector, 'collection', denoise, 't2i_adapter'); - } else { - g.deleteNode(t2iAdapterCollector.id); + const t2iAdapterCollector = g.addNode({ + type: 'collect', + id: getPrefixedId('t2i_adapter_collector'), + }); + const t2iAdapterResult = await addT2IAdapters({ + manager, + entities: canvas.controlLayers.entities, + g, + rect: canvas.bbox.rect, + collector: t2iAdapterCollector, + model, + }); + if (t2iAdapterResult.addedT2IAdapters > 0) { + g.addEdge(t2iAdapterCollector, 'collection', denoise, 't2i_adapter'); + } else { + g.deleteNode(t2iAdapterCollector.id); + } } const ipAdapterCollect = g.addNode({ @@ -268,23 +273,26 @@ export const buildSD1Graph = async (state: RootState, manager: CanvasManager): P collector: ipAdapterCollect, model, }); + let totalIPAdaptersAdded = ipAdapterResult.addedIPAdapters; - const regionsResult = await addRegions({ - manager, - regions: canvas.regionalGuidance.entities, - g, - bbox: canvas.bbox.rect, - model, - posCond, - negCond, - posCondCollect, - negCondCollect, - ipAdapterCollect, - fluxReduxCollect: null, - }); + if (manager) { + const regionsResult = await addRegions({ + manager, + regions: canvas.regionalGuidance.entities, + g, + bbox: canvas.bbox.rect, + model, + posCond, + negCond, + posCondCollect, + negCondCollect, + ipAdapterCollect, + fluxReduxCollect: null, + }); + + totalIPAdaptersAdded += regionsResult.reduce((acc, r) => acc + r.addedIPAdapters, 0); + } - const totalIPAdaptersAdded = - ipAdapterResult.addedIPAdapters + regionsResult.reduce((acc, r) => acc + r.addedIPAdapters, 0); if (totalIPAdaptersAdded > 0) { g.addEdge(ipAdapterCollect, 'collection', denoise, 'ip_adapter'); } else { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts index c0c864daaa..7843c6796f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts @@ -3,7 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectMainModelConfig,selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage'; import { addInpaint } from 'features/nodes/util/graph/generation/addInpaint'; @@ -11,6 +11,7 @@ import { addNSFWChecker } from 'features/nodes/util/graph/generation/addNSFWChec import { addOutpaint } from 'features/nodes/util/graph/generation/addOutpaint'; import { addTextToImage } from 'features/nodes/util/graph/generation/addTextToImage'; import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -19,15 +20,14 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; -import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; const log = logger('system'); -export const buildSD3Graph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildSD3Graph = async (state: RootState, manager?: CanvasManager | null): Promise => { + const generationMode = await getGenerationMode(manager); log.debug({ generationMode }, 'Building SD3 graph'); const model = selectMainModelConfig(state); @@ -134,6 +134,7 @@ export const buildSD3Graph = async (state: RootState, manager: CanvasManager): P canvasOutput = addTextToImage({ g, l2i, originalSize, scaledSize }); g.upsertMetadata({ generation_mode: 'sd3_txt2img' }); } else if (generationMode === 'img2img') { + assert(manager, 'Need manager to do img2img'); canvasOutput = await addImageToImage({ g, manager, @@ -149,6 +150,7 @@ export const buildSD3Graph = async (state: RootState, manager: CanvasManager): P }); g.upsertMetadata({ generation_mode: 'sd3_img2img' }); } else if (generationMode === 'inpaint') { + assert(manager, 'Need manager to do inpaint'); canvasOutput = await addInpaint({ state, g, @@ -166,6 +168,7 @@ export const buildSD3Graph = async (state: RootState, manager: CanvasManager): P }); g.upsertMetadata({ generation_mode: 'sd3_inpaint' }); } else if (generationMode === 'outpaint') { + assert(manager, 'Need manager to do outpaint'); canvasOutput = await addOutpaint({ state, g, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index f6c167770e..8da0df7d3d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -3,7 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addControlNets, addT2IAdapters } from 'features/nodes/util/graph/generation/addControlAdapters'; import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage'; @@ -16,6 +16,7 @@ import { addSDXLRefiner } from 'features/nodes/util/graph/generation/addSDXLRefi import { addSeamless } from 'features/nodes/util/graph/generation/addSeamless'; import { addTextToImage } from 'features/nodes/util/graph/generation/addTextToImage'; import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker'; +import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, @@ -24,7 +25,6 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; -import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -33,8 +33,8 @@ import { addRegions } from './addRegions'; const log = logger('system'); -export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): Promise => { - const generationMode = await manager.compositor.getGenerationMode(); +export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | null): Promise => { + const generationMode = await getGenerationMode(manager); log.debug({ generationMode }, 'Building SDXL graph'); const model = selectMainModelConfig(state); @@ -177,6 +177,7 @@ export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): canvasOutput = addTextToImage({ g, l2i, originalSize, scaledSize }); g.upsertMetadata({ generation_mode: 'sdxl_txt2img' }); } else if (generationMode === 'img2img') { + assert(manager, 'Need manager to do img2img'); canvasOutput = await addImageToImage({ g, manager, @@ -192,6 +193,7 @@ export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): }); g.upsertMetadata({ generation_mode: 'sdxl_img2img' }); } else if (generationMode === 'inpaint') { + assert(manager, 'Need manager to do inpaint'); canvasOutput = await addInpaint({ state, g, @@ -209,6 +211,7 @@ export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): }); g.upsertMetadata({ generation_mode: 'sdxl_inpaint' }); } else if (generationMode === 'outpaint') { + assert(manager, 'Need manager to do outpaint'); canvasOutput = await addOutpaint({ state, g, @@ -229,40 +232,42 @@ export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): assert>(false); } - const controlNetCollector = g.addNode({ - type: 'collect', - id: getPrefixedId('control_net_collector'), - }); - const controlNetResult = await addControlNets({ - manager, - entities: canvas.controlLayers.entities, - g, - rect: canvas.bbox.rect, - collector: controlNetCollector, - model, - }); - if (controlNetResult.addedControlNets > 0) { - g.addEdge(controlNetCollector, 'collection', denoise, 'control'); - } else { - g.deleteNode(controlNetCollector.id); - } + if (manager) { + const controlNetCollector = g.addNode({ + type: 'collect', + id: getPrefixedId('control_net_collector'), + }); + const controlNetResult = await addControlNets({ + manager, + entities: canvas.controlLayers.entities, + g, + rect: canvas.bbox.rect, + collector: controlNetCollector, + model, + }); + if (controlNetResult.addedControlNets > 0) { + g.addEdge(controlNetCollector, 'collection', denoise, 'control'); + } else { + g.deleteNode(controlNetCollector.id); + } - const t2iAdapterCollector = g.addNode({ - type: 'collect', - id: getPrefixedId('t2i_adapter_collector'), - }); - const t2iAdapterResult = await addT2IAdapters({ - manager, - entities: canvas.controlLayers.entities, - g, - rect: canvas.bbox.rect, - collector: t2iAdapterCollector, - model, - }); - if (t2iAdapterResult.addedT2IAdapters > 0) { - g.addEdge(t2iAdapterCollector, 'collection', denoise, 't2i_adapter'); - } else { - g.deleteNode(t2iAdapterCollector.id); + const t2iAdapterCollector = g.addNode({ + type: 'collect', + id: getPrefixedId('t2i_adapter_collector'), + }); + const t2iAdapterResult = await addT2IAdapters({ + manager, + entities: canvas.controlLayers.entities, + g, + rect: canvas.bbox.rect, + collector: t2iAdapterCollector, + model, + }); + if (t2iAdapterResult.addedT2IAdapters > 0) { + g.addEdge(t2iAdapterCollector, 'collection', denoise, 't2i_adapter'); + } else { + g.deleteNode(t2iAdapterCollector.id); + } } const ipAdapterCollect = g.addNode({ @@ -275,23 +280,25 @@ export const buildSDXLGraph = async (state: RootState, manager: CanvasManager): collector: ipAdapterCollect, model, }); + let totalIPAdaptersAdded = ipAdapterResult.addedIPAdapters; - const regionsResult = await addRegions({ - manager, - regions: canvas.regionalGuidance.entities, - g, - bbox: canvas.bbox.rect, - model, - posCond, - negCond, - posCondCollect, - negCondCollect, - ipAdapterCollect, - fluxReduxCollect: null, - }); + if (manager) { + const regionsResult = await addRegions({ + manager, + regions: canvas.regionalGuidance.entities, + g, + bbox: canvas.bbox.rect, + model, + posCond, + negCond, + posCondCollect, + negCondCollect, + ipAdapterCollect, + fluxReduxCollect: null, + }); + totalIPAdaptersAdded += regionsResult.reduce((acc, r) => acc + r.addedIPAdapters, 0); + } - const totalIPAdaptersAdded = - ipAdapterResult.addedIPAdapters + regionsResult.reduce((acc, r) => acc + r.addedIPAdapters, 0); if (totalIPAdaptersAdded > 0) { g.addEdge(ipAdapterCollect, 'collection', denoise, 'ip_adapter'); } else { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts new file mode 100644 index 0000000000..b458088d5a --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts @@ -0,0 +1,9 @@ +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import type { GenerationMode } from 'features/controlLayers/store/types'; + +export const getGenerationMode = async (manager?: CanvasManager | null): Promise => { + if (!manager) { + return 'txt2img'; + } + return await manager.compositor.getGenerationMode(); +}; diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index ebe96a21be..f17542a4df 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -2,13 +2,13 @@ import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; -import { $true } from 'app/store/nanostores/util'; +import { $false } from 'app/store/nanostores/util'; import type { AppDispatch, AppStore } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import type { AppConfig } from 'app/types/invokeai'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectMainModelConfig,selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import type { CanvasState, ParamsState } from 'features/controlLayers/store/types'; import { @@ -145,11 +145,11 @@ export const useReadinessWatcher = () => { const config = useAppSelector(selectConfigSlice); const templates = useStore($templates); const isConnected = useStore($isConnected); - const canvasIsFiltering = useStore(canvasManager?.stateApi.$isFiltering ?? $true); - const canvasIsTransforming = useStore(canvasManager?.stateApi.$isTransforming ?? $true); - const canvasIsRasterizing = useStore(canvasManager?.stateApi.$isRasterizing ?? $true); - const canvasIsSelectingObject = useStore(canvasManager?.stateApi.$isSegmenting ?? $true); - const canvasIsCompositing = useStore(canvasManager?.compositor.$isBusy ?? $true); + const canvasIsFiltering = useStore(canvasManager?.stateApi.$isFiltering ?? $false); + const canvasIsTransforming = useStore(canvasManager?.stateApi.$isTransforming ?? $false); + const canvasIsRasterizing = useStore(canvasManager?.stateApi.$isRasterizing ?? $false); + const canvasIsSelectingObject = useStore(canvasManager?.stateApi.$isSegmenting ?? $false); + const canvasIsCompositing = useStore(canvasManager?.compositor.$isBusy ?? $false); const isInPublishFlow = useStore($isInPublishFlow); const { isChatGPT4oHighModelDisabled } = useIsModelDisabled(); From aa3b2106d46cdb834d6e7a796c8cbb5459312435 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 23 May 2025 13:43:31 +1000 Subject: [PATCH 011/210] refactor(ui): canvas flow (wip) --- .../web/src/app/components/GlobalHookIsolator.tsx | 7 +++++++ .../components/CanvasMainPanelContent.tsx | 14 +++++++++----- .../util/graph/generation/buildChatGPT4oGraph.ts | 8 ++++---- .../util/graph/generation/buildCogView4Graph.ts | 4 ++-- .../nodes/util/graph/generation/buildFLUXGraph.ts | 4 ++-- .../util/graph/generation/buildImagen3Graph.ts | 4 ++-- .../util/graph/generation/buildImagen4Graph.ts | 4 ++-- .../nodes/util/graph/generation/buildSD1Graph.ts | 4 ++-- .../nodes/util/graph/generation/buildSD3Graph.ts | 4 ++-- .../nodes/util/graph/generation/buildSDXLGraph.ts | 4 ++-- 10 files changed, 34 insertions(+), 23 deletions(-) diff --git a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx index 4be48fcd2c..ef45771119 100644 --- a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx @@ -21,8 +21,11 @@ import i18n from 'i18n'; import { size } from 'lodash-es'; import { memo, useEffect } from 'react'; import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo'; +import { useGetQueueCountsByDestinationQuery } from 'services/api/endpoints/queue'; import { useSocketIO } from 'services/events/useSocketIO'; +const queueCountArg = { destination: 'canvas' }; + /** * GlobalHookIsolator is a logical component that runs global hooks in an isolated component, so that they do not * cause needless re-renders of any other components. @@ -41,6 +44,10 @@ export const GlobalHookIsolator = memo( useGetOpenAPISchemaQuery(); useSyncLoggingConfig(); + // Persistent subscription to the queue counts query - canvas relies on this to know if there are pending + // and/or in progress canvas sessions. + useGetQueueCountsByDestinationQuery(queueCountArg); + useEffect(() => { i18n.changeLanguage(language); }, [language]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 2861a1db54..551aabb8c6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -28,6 +28,7 @@ import { import { selectIsCanvasEmpty, selectIsSessionStarted } from 'features/controlLayers/store/selectors'; import { memo, useCallback } from 'react'; import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; +import { assert } from 'tsafe'; import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress'; @@ -49,10 +50,10 @@ const MenuContent = memo(() => { MenuContent.displayName = 'MenuContent'; export const CanvasMainPanelContent = memo(() => { - const isSessionStarted = useAppSelector(selectIsSessionStarted); const isCanvasEmpty = useAppSelector(selectIsCanvasEmpty); + const isSessionStarted = useAppSelector(selectIsSessionStarted); - if (!isSessionStarted && isCanvasEmpty) { + if (!isSessionStarted) { return ; } @@ -60,7 +61,11 @@ export const CanvasMainPanelContent = memo(() => { return ; } - return ; + if (isSessionStarted && !isCanvasEmpty) { + return ; + } + + assert(false); }); CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; @@ -103,7 +108,6 @@ const NoActiveSession = memo(() => { ); }); NoActiveSession.displayName = 'NoActiveSession'; - const SimpleActiveSession = memo(() => { const isStaging = useAppSelector(selectIsStaging); const selectedImage = useAppSelector(selectSelectedImage); @@ -114,7 +118,7 @@ const SimpleActiveSession = memo(() => { Simple Session (staging view) {isStaging && 'STAGING'} {selectedImage && } - + {stagedImages.map(({ imageDTO }) => ( ))} diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index c53da0e1ec..dbad701bc9 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -79,8 +79,8 @@ export const buildChatGPT4oGraph = async ( aspect_ratio: bbox.aspectRatio.id, reference_images, use_cache: false, - is_intermediate, - board, + is_intermediate: true, + board: undefined, }); g.upsertMetadata({ positive_prompt: positivePrompt, @@ -112,8 +112,8 @@ export const buildChatGPT4oGraph = async ( base_image: { image_name }, reference_images, use_cache: false, - is_intermediate, - board, + is_intermediate: true, + board: undefined, }); g.upsertMetadata({ positive_prompt: positivePrompt, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts index b0b29774e9..fb63235f9c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts @@ -186,9 +186,9 @@ export const buildCogView4Graph = async ( g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate, + is_intermediate: true, use_cache: false, - board, + board: undefined, }); g.setMetadataReceivingNode(canvasOutput); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts index 36abfb6a4c..136769b492 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts @@ -345,9 +345,9 @@ export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate, + is_intermediate: true, use_cache: false, - board, + board: undefined, }); g.setMetadataReceivingNode(canvasOutput); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts index 6624aa73af..6f5497a64a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts @@ -61,8 +61,8 @@ export const buildImagen3Graph = async ( enhance_prompt: true, // When enhance_prompt is true, Imagen3 will return a new image every time, ignoring the seed. use_cache: false, - is_intermediate, - board, + is_intermediate: true, + board: undefined, }); g.upsertMetadata({ positive_prompt: positivePrompt, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts index 698552be54..b5e866312d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts @@ -61,8 +61,8 @@ export const buildImagen4Graph = async ( enhance_prompt: true, // When enhance_prompt is true, Imagen4 will return a new image every time, ignoring the seed. use_cache: false, - is_intermediate, - board, + is_intermediate: true, + board: undefined, }); g.upsertMetadata({ positive_prompt: positivePrompt, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index 0abdadfaba..7f869f69f8 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -317,9 +317,9 @@ export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate, + is_intermediate: true, use_cache: false, - board, + board: undefined, }); g.setMetadataReceivingNode(canvasOutput); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts index 7843c6796f..ab1acf7806 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts @@ -207,9 +207,9 @@ export const buildSD3Graph = async (state: RootState, manager?: CanvasManager | g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate, + is_intermediate: true, use_cache: false, - board, + board: undefined, }); g.setMetadataReceivingNode(canvasOutput); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index 8da0df7d3d..de19fe9151 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -323,9 +323,9 @@ export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate, + is_intermediate: true, use_cache: false, - board, + board: undefined, }); g.setMetadataReceivingNode(canvasOutput); From c0428ee7efbdeb71a8ae098f1eef294f315bf98e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 23 May 2025 19:07:26 +1000 Subject: [PATCH 012/210] refactor(ui): canvas flow (wip) --- .../listeners/enqueueRequestedLinear.ts | 4 + invokeai/frontend/web/src/app/store/store.ts | 4 +- .../components/CanvasMainPanelContent.tsx | 220 ++++++++++++++++-- .../NewSessionConfirmationAlertDialog.tsx | 6 +- .../konva/CanvasStagingAreaModule.ts | 2 +- .../features/controlLayers/store/actions.ts | 6 +- .../store/canvasSettingsSlice.ts | 6 +- .../controlLayers/store/canvasSlice.ts | 9 +- .../store/canvasStagingAreaSlice.ts | 85 ++++--- .../features/controlLayers/store/selectors.ts | 1 - .../src/features/controlLayers/store/types.ts | 1 - .../nodes/util/graph/graphBuilderUtils.ts | 6 +- .../services/events/onInvocationComplete.tsx | 18 +- .../web/src/services/events/stores.ts | 12 + 14 files changed, 297 insertions(+), 83 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts index 8ed57f33ad..c6b38c517a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -5,6 +5,7 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware' import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; import { withResult, withResultAsync } from 'common/util/result'; import { parseify } from 'common/util/serialize'; +import { canvasSessionStarted, selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { $canvasManager } from 'features/controlLayers/store/ephemeral'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph'; @@ -115,6 +116,9 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) try { await req.unwrap(); + if (!selectCanvasSessionType(state)) { + dispatch(canvasSessionStarted({ sessionType: 'simple' })); + } log.debug(parseify({ batchConfig: prepareBatchResult.value }), 'Enqueued batch'); } catch (error) { log.error({ error: serializeError(error as Error) }, 'Failed to enqueue batch'); diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index c0eca358ab..a9f274c5cb 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -9,7 +9,7 @@ import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/contr import { canvasPersistConfig, canvasSlice, canvasUndoableConfig } from 'features/controlLayers/store/canvasSlice'; import { canvasStagingAreaPersistConfig, - canvasStagingAreaSlice, + canvasSessionSlice, } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice'; import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice'; @@ -65,7 +65,7 @@ const allReducers = { [stylePresetSlice.name]: stylePresetSlice.reducer, [paramsSlice.name]: paramsSlice.reducer, [canvasSettingsSlice.name]: canvasSettingsSlice.reducer, - [canvasStagingAreaSlice.name]: canvasStagingAreaSlice.reducer, + [canvasSessionSlice.name]: canvasSessionSlice.reducer, [lorasSlice.name]: lorasSlice.reducer, [workflowLibrarySlice.name]: workflowLibrarySlice.reducer, }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 551aabb8c6..a4c554073c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,5 +1,6 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, ContextMenu, Flex, IconButton, Image, Menu, MenuButton, MenuList, Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; @@ -18,17 +19,32 @@ import { StagingAreaToolbar } from 'features/controlLayers/components/StagingAre import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { newCanvasSessionRequested } from 'features/controlLayers/store/actions'; +import { canvasReset, newAdvancedCanvasSessionRequested } from 'features/controlLayers/store/actions'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; import { + selectCanvasSessionType, selectIsStaging, selectSelectedImage, + selectStagedImageIndex, selectStagedImages, + stagingAreaImageSelected, + stagingAreaImageStaged, + stagingAreaNextStagedImageSelected, + stagingAreaPrevStagedImageSelected, } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { selectIsCanvasEmpty, selectIsSessionStarted } from 'features/controlLayers/store/selectors'; -import { memo, useCallback } from 'react'; +import { isImageField, type ProgressImage } from 'features/nodes/types/common'; +import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils'; +import type { Atom } from 'nanostores'; +import { atom } from 'nanostores'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useHotkeys } from 'react-hotkeys-hook'; import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; -import { assert } from 'tsafe'; +import { getImageDTOSafe } from 'services/api/endpoints/images'; +import type { ImageDTO, S } from 'services/api/types'; +import { $socket } from 'services/events/stores'; +import type { Equals } from 'tsafe'; +import { assert, objectEntries } from 'tsafe'; import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress'; @@ -50,22 +66,21 @@ const MenuContent = memo(() => { MenuContent.displayName = 'MenuContent'; export const CanvasMainPanelContent = memo(() => { - const isCanvasEmpty = useAppSelector(selectIsCanvasEmpty); - const isSessionStarted = useAppSelector(selectIsSessionStarted); + const sessionType = useAppSelector(selectCanvasSessionType); - if (!isSessionStarted) { + if (sessionType === null) { return ; } - if (isSessionStarted && isCanvasEmpty) { + if (sessionType === 'simple') { return ; } - if (isSessionStarted && !isCanvasEmpty) { + if (sessionType === 'advanced') { return ; } - assert(false); + assert>(false, 'Unexpected sessionType'); }); CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; @@ -73,7 +88,7 @@ CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; const NoActiveSession = memo(() => { const dispatch = useAppDispatch(); const newSesh = useCallback(() => { - dispatch(newCanvasSessionRequested()); + dispatch(newAdvancedCanvasSessionRequested()); }, [dispatch]); return ( @@ -108,26 +123,189 @@ const NoActiveSession = memo(() => { ); }); NoActiveSession.displayName = 'NoActiveSession'; + +type EphemeralProgressImage = { sessionId: string; image: ProgressImage }; + const SimpleActiveSession = memo(() => { + const dispatch = useAppDispatch(); const isStaging = useAppSelector(selectIsStaging); - const selectedImage = useAppSelector(selectSelectedImage); - const stagedImages = useAppSelector(selectStagedImages); + const socket = useStore($socket); + const [$progressImage] = useState(() => atom(null)); + + useEffect(() => { + if (!socket) { + return; + } + const onInvocationProgress = (event: S['InvocationProgressEvent']) => { + if (!event) { + return; + } + if (event.origin !== 'canvas') { + return; + } + if (!event.image) { + return; + } + $progressImage.set({ sessionId: event.session_id, image: event.image }); + }; + const onInvocationComplete = async (event: S['InvocationCompleteEvent']) => { + const progressImage = $progressImage.get(); + if (!progressImage) { + return; + } + if (progressImage.sessionId !== event.session_id) { + return; + } + if (!isCanvasOutputEvent(event)) { + return; + } + let imageDTO: ImageDTO | null = null; + for (const [_name, value] of objectEntries(event.result)) { + if (isImageField(value)) { + imageDTO = await getImageDTOSafe(value.image_name); + break; + } + } + if (!imageDTO) { + return; + } + flushSync(() => { + dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); + }); + $progressImage.set(null); + }; + + const onQueueItemStatusChanged = (event: S['QueueItemStatusChangedEvent']) => { + const progressImage = $progressImage.get(); + if (!progressImage) { + return; + } + if (progressImage.sessionId !== event.session_id) { + return; + } + if (event.status !== 'canceled' && event.status !== 'failed') { + return; + } + $progressImage.set(null); + }; + console.log('SUB session preview image listeners'); + socket.on('invocation_progress', onInvocationProgress); + socket.on('invocation_complete', onInvocationComplete); + socket.on('queue_item_status_changed', onQueueItemStatusChanged); + + return () => { + console.log('UNSUB session preview image listeners'); + socket.off('invocation_progress', onInvocationProgress); + socket.off('invocation_complete', onInvocationComplete); + socket.off('queue_item_status_changed', onQueueItemStatusChanged); + }; + }, [$progressImage, dispatch, socket]); + + const onReset = useCallback(() => { + dispatch(canvasReset()); + }, [dispatch]); + + const selectNext = useCallback(() => { + dispatch(stagingAreaNextStagedImageSelected()); + }, [dispatch]); + + useHotkeys(['right'], selectNext, { preventDefault: true }, [selectNext]); + + const selectPrev = useCallback(() => { + dispatch(stagingAreaPrevStagedImageSelected()); + }, [dispatch]); + + useHotkeys(['left'], selectPrev, { preventDefault: true }, [selectPrev]); + return ( - - Simple Session (staging view) {isStaging && 'STAGING'} - - {selectedImage && } - - {stagedImages.map(({ imageDTO }) => ( - - ))} + + + Simple Session (staging view) {isStaging && 'STAGING'} + + + + ); }); SimpleActiveSession.displayName = 'SimpleActiveSession'; +const SelectedImage = memo(({ $progressImage }: { $progressImage: Atom }) => { + const progressImage = useStore($progressImage); + const selectedImage = useAppSelector(selectSelectedImage); + + if (progressImage) { + return ( + + + + ); + } + + if (selectedImage) { + return ( + + + + ); + } + + return No images; +}); +SelectedImage.displayName = 'SelectedImage'; + +const SessionImages = memo(() => { + const stagedImages = useAppSelector(selectStagedImages); + return ( + + {stagedImages.map(({ imageDTO }, index) => ( + + ))} + + ); +}); +SessionImages.displayName = 'SessionImages'; + +const sx = { + '&[data-is-selected="false"]': { + opacity: 0.5, + }, +} satisfies SystemStyleObject; +const SessionImage = memo(({ index, imageDTO }: { index: number; imageDTO: ImageDTO }) => { + const dispatch = useAppDispatch(); + const selectedImageIndex = useAppSelector(selectStagedImageIndex); + const onClick = useCallback(() => { + dispatch(stagingAreaImageSelected({ index })); + }, [dispatch, index]); + return ( + + ); +}); +SessionImage.displayName = 'SessionImage'; + const CanvasActiveSession = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx index f57818964c..0e341380ad 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx @@ -2,7 +2,7 @@ import { Checkbox, ConfirmationAlertDialog, Flex, FormControl, FormLabel, Text } import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { buildUseBoolean } from 'common/hooks/useBoolean'; -import { newCanvasSessionRequested, newGallerySessionRequested } from 'features/controlLayers/store/actions'; +import { newAdvancedCanvasSessionRequested, newSimpleCanvasSessionRequested } from 'features/controlLayers/store/actions'; import { selectSystemShouldConfirmOnNewSession, shouldConfirmOnNewSessionToggled, @@ -20,7 +20,7 @@ export const useNewGallerySession = () => { const newSessionDialog = useNewGallerySessionDialog(); const newGallerySessionImmediate = useCallback(() => { - dispatch(newGallerySessionRequested()); + dispatch(newSimpleCanvasSessionRequested()); dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); @@ -41,7 +41,7 @@ export const useNewCanvasSession = () => { const newSessionDialog = useNewCanvasSessionDialog(); const newCanvasSessionImmediate = useCallback(() => { - dispatch(newCanvasSessionRequested()); + dispatch(newAdvancedCanvasSessionRequested()); dispatch(activeTabCanvasRightPanelChanged('layers')); }, [dispatch]); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index 973ae61b33..a993ed7ef6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts @@ -78,7 +78,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { const { x, y } = this.manager.stateApi.getBbox().rect; const shouldShowStagedImage = this.$shouldShowStagedImage.get(); - this.selectedImage = stagingArea.stagedImages[stagingArea.selectedStagedImageIndex] ?? null; + this.selectedImage = stagingArea.images[stagingArea.selectedImageIndex] ?? null; this.konva.group.position({ x, y }); if (this.selectedImage) { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/actions.ts b/invokeai/frontend/web/src/features/controlLayers/store/actions.ts index f91ae31a1f..a63d19946d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/actions.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/actions.ts @@ -2,6 +2,6 @@ import { createAction, isAnyOf } from '@reduxjs/toolkit'; // Needed to split this from canvasSlice.ts to avoid circular dependencies export const canvasReset = createAction('canvas/canvasReset'); -export const newGallerySessionRequested = createAction('canvas/newGallerySessionRequested'); -export const newCanvasSessionRequested = createAction('canvas/newCanvasSessionRequested'); -export const newSessionRequested = isAnyOf(newGallerySessionRequested, newCanvasSessionRequested); +export const newSimpleCanvasSessionRequested = createAction('canvas/newSimpleCanvasSessionRequested'); +export const newAdvancedCanvasSessionRequested = createAction('canvas/newAdvancedCanvasSessionRequested'); +export const newSessionRequested = isAnyOf(newSimpleCanvasSessionRequested, newAdvancedCanvasSessionRequested); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index cbd3dd2c01..6e8b3f0599 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -1,7 +1,7 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { newCanvasSessionRequested, newGallerySessionRequested } from 'features/controlLayers/store/actions'; +import { newAdvancedCanvasSessionRequested, newSimpleCanvasSessionRequested } from 'features/controlLayers/store/actions'; import type { RgbaColor } from 'features/controlLayers/store/types'; type CanvasSettingsState = { @@ -158,10 +158,10 @@ export const canvasSettingsSlice = createSlice({ }, }, extraReducers(builder) { - builder.addCase(newGallerySessionRequested, (state) => { + builder.addCase(newSimpleCanvasSessionRequested, (state) => { state.sendToCanvas = false; }); - builder.addCase(newCanvasSessionRequested, (state) => { + builder.addCase(newAdvancedCanvasSessionRequested, (state) => { state.sendToCanvas = true; }); }, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index d02aaed004..d73d0cd317 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -7,8 +7,8 @@ import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMul import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset, - newCanvasSessionRequested, - newGallerySessionRequested, + newAdvancedCanvasSessionRequested, + newSimpleCanvasSessionRequested, } from 'features/controlLayers/store/actions'; import { modelChanged } from 'features/controlLayers/store/paramsSlice'; import { @@ -1806,12 +1806,11 @@ export const canvasSlice = createSlice({ syncScaledSize(state); } }); - builder.addCase(newGallerySessionRequested, (state) => { + builder.addCase(newSimpleCanvasSessionRequested, (state) => { return resetState(state); }); - builder.addCase(newCanvasSessionRequested, (state) => { + builder.addCase(newAdvancedCanvasSessionRequested, (state) => { const newState = resetState(state); - newState.isSessionStarted = true; return newState; }); }, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index 660ac1a680..2dc441a801 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -1,51 +1,73 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { canvasReset } from 'features/controlLayers/store/actions'; +import { + canvasReset, + newAdvancedCanvasSessionRequested, + newSimpleCanvasSessionRequested, +} from 'features/controlLayers/store/actions'; import type { StagingAreaImage } from 'features/controlLayers/store/types'; import { selectCanvasQueueCounts } from 'services/api/endpoints/queue'; -import { newSessionRequested } from './actions'; - type CanvasStagingAreaState = { - stagedImages: StagingAreaImage[]; - selectedStagedImageIndex: number; + sessionType: 'simple' | 'advanced' | null; + images: StagingAreaImage[]; + selectedImageIndex: number; }; const initialState: CanvasStagingAreaState = { - stagedImages: [], - selectedStagedImageIndex: 0, + sessionType: null, + images: [], + selectedImageIndex: 0, }; -export const canvasStagingAreaSlice = createSlice({ - name: 'canvasStagingArea', +export const canvasSessionSlice = createSlice({ + name: 'canvasSession', initialState, reducers: { stagingAreaImageStaged: (state, action: PayloadAction<{ stagingAreaImage: StagingAreaImage }>) => { const { stagingAreaImage } = action.payload; - state.stagedImages.push(stagingAreaImage); - state.selectedStagedImageIndex = state.stagedImages.length - 1; + state.images.push(stagingAreaImage); + state.selectedImageIndex = state.images.length - 1; + }, + stagingAreaImageSelected: (state, action: PayloadAction<{ index: number }>) => { + const { index } = action.payload; + state.selectedImageIndex = index; }, stagingAreaNextStagedImageSelected: (state) => { - state.selectedStagedImageIndex = (state.selectedStagedImageIndex + 1) % state.stagedImages.length; + state.selectedImageIndex = (state.selectedImageIndex + 1) % state.images.length; }, stagingAreaPrevStagedImageSelected: (state) => { - state.selectedStagedImageIndex = - (state.selectedStagedImageIndex - 1 + state.stagedImages.length) % state.stagedImages.length; + state.selectedImageIndex = (state.selectedImageIndex - 1 + state.images.length) % state.images.length; }, stagingAreaStagedImageDiscarded: (state, action: PayloadAction<{ index: number }>) => { const { index } = action.payload; - state.stagedImages.splice(index, 1); - state.selectedStagedImageIndex = Math.min(state.selectedStagedImageIndex, state.stagedImages.length - 1); + state.images.splice(index, 1); + state.selectedImageIndex = Math.min(state.selectedImageIndex, state.images.length - 1); }, stagingAreaReset: (state) => { - state.stagedImages = []; - state.selectedStagedImageIndex = 0; + state.images = []; + state.selectedImageIndex = 0; + }, + canvasSessionStarted: (state, action: PayloadAction<{ sessionType: CanvasStagingAreaState['sessionType'] }>) => { + const { sessionType } = action.payload; + state.sessionType = sessionType; + state.images = []; + state.selectedImageIndex = 0; }, }, extraReducers(builder) { builder.addCase(canvasReset, () => deepClone(initialState)); - builder.addMatcher(newSessionRequested, () => deepClone(initialState)); + builder.addCase(newSimpleCanvasSessionRequested, () => { + const state = deepClone(initialState); + state.sessionType === 'simple'; + return state; + }); + builder.addCase(newAdvancedCanvasSessionRequested, () => { + const state = deepClone(initialState); + state.sessionType === 'advanced'; + return state; + }); }, }); @@ -53,9 +75,11 @@ export const { stagingAreaImageStaged, stagingAreaStagedImageDiscarded, stagingAreaReset, + stagingAreaImageSelected, stagingAreaNextStagedImageSelected, stagingAreaPrevStagedImageSelected, -} = canvasStagingAreaSlice.actions; + canvasSessionStarted, +} = canvasSessionSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const migrate = (state: any): any => { @@ -63,13 +87,13 @@ const migrate = (state: any): any => { }; export const canvasStagingAreaPersistConfig: PersistConfig = { - name: canvasStagingAreaSlice.name, + name: canvasSessionSlice.name, initialState, migrate, persistDenylist: [], }; -export const selectCanvasStagingAreaSlice = (s: RootState) => s.canvasStagingArea; +export const selectCanvasStagingAreaSlice = (s: RootState) => s[canvasSessionSlice.name]; /** * Selects if we should be staging images. This is true if: @@ -80,7 +104,7 @@ export const selectIsStaging = createSelector( selectCanvasQueueCounts, selectCanvasStagingAreaSlice, ({ data }, staging) => { - if (staging.stagedImages.length > 0) { + if (staging.images.length > 0) { return true; } if (!data) { @@ -91,17 +115,18 @@ export const selectIsStaging = createSelector( ); export const selectStagedImageIndex = createSelector( selectCanvasStagingAreaSlice, - (stagingArea) => stagingArea.selectedStagedImageIndex + (stagingArea) => stagingArea.selectedImageIndex ); export const selectSelectedImage = createSelector( [selectCanvasStagingAreaSlice, selectStagedImageIndex], - (stagingArea, index) => stagingArea.stagedImages[index] ?? null -); -export const selectStagedImages = createSelector( - selectCanvasStagingAreaSlice, - (stagingArea) => stagingArea.stagedImages + (stagingArea, index) => stagingArea.images[index] ?? null ); +export const selectStagedImages = createSelector(selectCanvasStagingAreaSlice, (stagingArea) => stagingArea.images); export const selectImageCount = createSelector( selectCanvasStagingAreaSlice, - (stagingArea) => stagingArea.stagedImages.length + (stagingArea) => stagingArea.images.length +); +export const selectCanvasSessionType = createSelector( + selectCanvasStagingAreaSlice, + (canvasSession) => canvasSession.sessionType ); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts index fe37d1891b..cffa517fd7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts @@ -409,7 +409,6 @@ export const selectCanvasMetadata = createSelector( } ); -export const selectIsSessionStarted = createCanvasSelector(({ isSessionStarted }) => isSessionStarted); export const selectIsCanvasEmpty = createCanvasSelector( ({ controlLayers, inpaintMasks, rasterLayers, regionalGuidance }) => { // Check it all manually - could use lodash isEqual, but this selector will be called very often! diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 09b540f951..165ad2c460 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -561,7 +561,6 @@ const zReferenceImages = z.object({ }); const zCanvasState = z.object({ _version: z.literal(3).default(3), - isSessionStarted: z.boolean().default(false), selectedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null), bookmarkedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null), inpaintMasks: zInpaintMasks.default({ isHidden: false, entities: [] }), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts index 3ebe337ed0..1163c462ad 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts @@ -8,7 +8,7 @@ import { buildPresetModifiedPrompt } from 'features/stylePresets/hooks/usePreset import { selectStylePresetSlice } from 'features/stylePresets/store/stylePresetSlice'; import { pick } from 'lodash-es'; import { selectListStylePresetsRequestState } from 'services/api/endpoints/stylePresets'; -import type { Invocation } from 'services/api/types'; +import type { Invocation, S } from 'services/api/types'; import { assert } from 'tsafe'; import type { MainModelLoaderNodes } from './types'; @@ -134,3 +134,7 @@ export const isMainModelWithoutUnet = (modelLoader: Invocation { + return data.invocation_source_id.split(':')[0] === CANVAS_OUTPUT_PREFIX; +}; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 95350f0fcc..2748dd1e6d 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -1,12 +1,10 @@ import { logger } from 'app/logging/logger'; import type { AppDispatch, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { stagingAreaImageStaged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; -import { CANVAS_OUTPUT_PREFIX } from 'features/nodes/util/graph/graphBuilderUtils'; import type { ApiTagDescription } from 'services/api'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; @@ -19,10 +17,6 @@ import type { JsonObject } from 'type-fest'; const log = logger('events'); -const isCanvasOutputNode = (data: S['InvocationCompleteEvent']) => { - return data.invocation_source_id.split(':')[0] === CANVAS_OUTPUT_PREFIX; -}; - const nodeTypeDenylist = ['load_image', 'image']; export const buildOnInvocationComplete = (getState: () => RootState, dispatch: AppDispatch) => { @@ -179,12 +173,12 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A if (data.destination === 'canvas') { // TODO(psyche): Can/should we let canvas handle this itself? - if (isCanvasOutputNode(data)) { - if (data.result.type === 'image_output') { - dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); - } - addImagesToGallery(data, [imageDTO]); - } + // if (isCanvasOutputEvent(data)) { + // if (data.result.type === 'image_output') { + // dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); + // } + // addImagesToGallery(data, [imageDTO]); + // } } else if (!imageDTO.is_intermediate) { // Desintaion is gallery addImagesToGallery(data, [imageDTO]); diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index b0b02e02c4..f46f3af079 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -9,6 +9,18 @@ export const $socketOptions = map>({}); export const $isConnected = atom(false); export const $lastProgressEvent = atom(null); export const $progressImage = computed($lastProgressEvent, (val) => val?.image ?? null); +export const $canvasProgressImage = computed($lastProgressEvent, (event) => { + if (!event) { + return null; + } + if (event.origin !== 'canvas') { + return null; + } + if (!event.image) { + return null; + } + return event.image; +}); export const $hasProgressImage = computed($lastProgressEvent, (val) => Boolean(val?.image)); export const $isProgressFromCanvas = computed($lastProgressEvent, (val) => val?.destination === 'canvas'); export const $invocationProgressMessage = computed($lastProgressEvent, (val) => { From ce5ae836898eff7ecb1cb35b45c46a0e8f8300cd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 27 May 2025 16:20:10 +1000 Subject: [PATCH 013/210] refactor(ui): canvas flow (wip) --- .../web/src/app/hooks/useStudioInitAction.ts | 10 +- .../addCommitStagingAreaImageListener.ts | 4 +- .../components/CanvasMainPanelContent.tsx | 5 +- .../NewSessionConfirmationAlertDialog.tsx | 6 +- .../konva/CanvasProgressImageModule.ts | 2 + .../features/controlLayers/store/actions.ts | 5 +- .../store/canvasSettingsSlice.ts | 20 -- .../controlLayers/store/canvasSlice.ts | 15 +- .../store/canvasStagingAreaSlice.ts | 32 +-- .../controlLayers/store/lorasSlice.ts | 5 +- .../controlLayers/store/paramsSlice.ts | 4 +- .../ImageViewer/CurrentImagePreview.tsx | 5 +- .../components/ImageViewer/ProgressImage.tsx | 5 +- .../web/src/features/hrf/store/hrfSlice.ts | 4 +- .../util/graph/generation/addFLUXFill.ts | 10 +- .../nodes/util/graph/generation/addInpaint.ts | 10 +- .../util/graph/generation/addOutpaint.ts | 11 +- .../graph/generation/buildChatGPT4oGraph.ts | 11 +- .../graph/generation/buildCogView4Graph.ts | 11 +- .../util/graph/generation/buildFLUXGraph.ts | 11 +- .../graph/generation/buildImagen3Graph.ts | 14 +- .../graph/generation/buildImagen4Graph.ts | 15 +- .../util/graph/generation/buildSD1Graph.ts | 11 +- .../util/graph/generation/buildSD3Graph.ts | 13 +- .../util/graph/generation/buildSDXLGraph.ts | 11 +- .../InvokeButtonTooltip.tsx | 23 +- .../queue/components/QueueControls.tsx | 11 - .../queue/components/SendToToggle.tsx | 205 ------------------ .../stylePresets/store/stylePresetSlice.ts | 8 +- .../web/src/features/ui/store/uiSlice.ts | 4 +- .../services/events/onInvocationComplete.tsx | 22 +- .../web/src/services/events/stores.ts | 27 ++- 32 files changed, 98 insertions(+), 452 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index f82949bb1e..34d2f8ea88 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -2,9 +2,8 @@ import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { withResultAsync } from 'common/util/result'; -import { canvasReset } from 'features/controlLayers/store/actions'; -import { settingsSendToCanvasChanged } from 'features/controlLayers/store/canvasSettingsSlice'; import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/util'; import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; @@ -91,9 +90,8 @@ export const useStudioInitAction = (action?: StudioInitAction) => { const overrides: Partial = { objects: [imageObject], }; - store.dispatch(canvasReset()); + store.dispatch(canvasSessionStarted({ sessionType: 'advanced' })); store.dispatch(rasterLayerAdded({ overrides, isSelected: true })); - store.dispatch(settingsSendToCanvasChanged(true)); store.dispatch(setActiveTab('canvas')); store.dispatch(sentImageToCanvas()); $imageViewer.set(false); @@ -164,15 +162,15 @@ export const useStudioInitAction = (action?: StudioInitAction) => { switch (destination) { case 'generation': // Go to the canvas tab, open the image viewer, and enable send-to-gallery mode + store.dispatch(canvasSessionStarted({ sessionType: 'simple' })); store.dispatch(setActiveTab('canvas')); store.dispatch(activeTabCanvasRightPanelChanged('gallery')); - store.dispatch(settingsSendToCanvasChanged(false)); $imageViewer.set(true); break; case 'canvas': // Go to the canvas tab, close the image viewer, and disable send-to-gallery mode + store.dispatch(canvasSessionStarted({ sessionType: 'advanced' })); store.dispatch(setActiveTab('canvas')); - store.dispatch(settingsSendToCanvasChanged(true)); $imageViewer.set(false); break; case 'workflows': diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts index 11dd39d867..a83da52d21 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts @@ -1,7 +1,7 @@ import { isAnyOf } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { canvasReset, newSessionRequested } from 'features/controlLayers/store/actions'; +import { canvasReset } from 'features/controlLayers/store/actions'; import { stagingAreaReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { toast } from 'features/toast/toast'; import { t } from 'i18next'; @@ -9,7 +9,7 @@ import { queueApi } from 'services/api/endpoints/queue'; const log = logger('canvas'); -const matchCanvasOrStagingAreaReset = isAnyOf(stagingAreaReset, canvasReset, newSessionRequested); +const matchCanvasOrStagingAreaReset = isAnyOf(stagingAreaReset, canvasReset); export const addStagingListeners = (startAppListening: AppStartListening) => { startAppListening({ diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index a4c554073c..e17df9b425 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -19,9 +19,10 @@ import { StagingAreaToolbar } from 'features/controlLayers/components/StagingAre import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { canvasReset, newAdvancedCanvasSessionRequested } from 'features/controlLayers/store/actions'; +import { canvasReset } from 'features/controlLayers/store/actions'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; import { + canvasSessionStarted, selectCanvasSessionType, selectIsStaging, selectSelectedImage, @@ -88,7 +89,7 @@ CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; const NoActiveSession = memo(() => { const dispatch = useAppDispatch(); const newSesh = useCallback(() => { - dispatch(newAdvancedCanvasSessionRequested()); + dispatch(canvasSessionStarted({ sessionType: 'advanced' })); }, [dispatch]); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx index 0e341380ad..fd65ae8095 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx @@ -2,7 +2,7 @@ import { Checkbox, ConfirmationAlertDialog, Flex, FormControl, FormLabel, Text } import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { buildUseBoolean } from 'common/hooks/useBoolean'; -import { newAdvancedCanvasSessionRequested, newSimpleCanvasSessionRequested } from 'features/controlLayers/store/actions'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectSystemShouldConfirmOnNewSession, shouldConfirmOnNewSessionToggled, @@ -20,7 +20,7 @@ export const useNewGallerySession = () => { const newSessionDialog = useNewGallerySessionDialog(); const newGallerySessionImmediate = useCallback(() => { - dispatch(newSimpleCanvasSessionRequested()); + dispatch(canvasSessionStarted({ sessionType: 'simple' })); dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); @@ -41,7 +41,7 @@ export const useNewCanvasSession = () => { const newSessionDialog = useNewCanvasSessionDialog(); const newCanvasSessionImmediate = useCallback(() => { - dispatch(newAdvancedCanvasSessionRequested()); + dispatch(canvasSessionStarted({ sessionType: 'advanced' })); dispatch(activeTabCanvasRightPanelChanged('layers')); }, [dispatch]); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts index 3dd469528c..c36933a6cf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts @@ -85,6 +85,8 @@ export class CanvasProgressImageModule extends CanvasModuleBase { if (data.destination !== 'canvas') { return; } + + // The staging area module handles _completed_ events. Only care about failed or canceled here. if (data.status === 'failed' || data.status === 'canceled') { this.$lastProgressEvent.set(null); this.$hasActiveGeneration.set(false); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/actions.ts b/invokeai/frontend/web/src/features/controlLayers/store/actions.ts index a63d19946d..9e1d9734cd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/actions.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/actions.ts @@ -1,7 +1,4 @@ -import { createAction, isAnyOf } from '@reduxjs/toolkit'; +import { createAction } from '@reduxjs/toolkit'; // Needed to split this from canvasSlice.ts to avoid circular dependencies export const canvasReset = createAction('canvas/canvasReset'); -export const newSimpleCanvasSessionRequested = createAction('canvas/newSimpleCanvasSessionRequested'); -export const newAdvancedCanvasSessionRequested = createAction('canvas/newAdvancedCanvasSessionRequested'); -export const newSessionRequested = isAnyOf(newSimpleCanvasSessionRequested, newAdvancedCanvasSessionRequested); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index 6e8b3f0599..ef9f86d189 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -1,7 +1,6 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { newAdvancedCanvasSessionRequested, newSimpleCanvasSessionRequested } from 'features/controlLayers/store/actions'; import type { RgbaColor } from 'features/controlLayers/store/types'; type CanvasSettingsState = { @@ -34,11 +33,6 @@ type CanvasSettingsState = { * The color to use when drawing lines or filling shapes. */ color: RgbaColor; - /** - * Whether to send generated images to canvas staging area. When disabled, generated images will be sent directly to - * the gallery. - */ - sendToCanvas: boolean; /** * Whether to composite inpainted/outpainted regions back onto the source image when saving canvas generations. * @@ -89,7 +83,6 @@ const initialState: CanvasSettingsState = { eraserWidth: 50, invertScrollForToolWidth: false, color: { r: 31, g: 160, b: 224, a: 1 }, // invokeBlue.500 - sendToCanvas: false, outputOnlyMaskedRegions: true, autoProcess: true, snapToGrid: true, @@ -126,9 +119,6 @@ export const canvasSettingsSlice = createSlice({ settingsInvertScrollForToolWidthChanged: (state, action: PayloadAction) => { state.invertScrollForToolWidth = action.payload; }, - settingsSendToCanvasChanged: (state, action: PayloadAction) => { - state.sendToCanvas = action.payload; - }, settingsOutputOnlyMaskedRegionsToggled: (state) => { state.outputOnlyMaskedRegions = !state.outputOnlyMaskedRegions; }, @@ -157,14 +147,6 @@ export const canvasSettingsSlice = createSlice({ state.pressureSensitivity = !state.pressureSensitivity; }, }, - extraReducers(builder) { - builder.addCase(newSimpleCanvasSessionRequested, (state) => { - state.sendToCanvas = false; - }); - builder.addCase(newAdvancedCanvasSessionRequested, (state) => { - state.sendToCanvas = true; - }); - }, }); export const { @@ -175,7 +157,6 @@ export const { settingsEraserWidthChanged, settingsColorChanged, settingsInvertScrollForToolWidthChanged, - settingsSendToCanvasChanged, settingsOutputOnlyMaskedRegionsToggled, settingsAutoProcessToggled, settingsSnapToGridToggled, @@ -212,7 +193,6 @@ export const selectBboxOverlay = createCanvasSettingsSelector((settings) => sett export const selectShowHUD = createCanvasSettingsSelector((settings) => settings.showHUD); export const selectAutoProcess = createCanvasSettingsSelector((settings) => settings.autoProcess); export const selectSnapToGrid = createCanvasSettingsSelector((settings) => settings.snapToGrid); -export const selectSendToCanvas = createCanvasSettingsSelector((canvasSettings) => canvasSettings.sendToCanvas); export const selectShowProgressOnCanvas = createCanvasSettingsSelector( (canvasSettings) => canvasSettings.showProgressOnCanvas ); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index d73d0cd317..1b135b0ce4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -5,11 +5,8 @@ import { moveOneToEnd, moveOneToStart, moveToEnd, moveToStart } from 'common/uti import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { - canvasReset, - newAdvancedCanvasSessionRequested, - newSimpleCanvasSessionRequested, -} from 'features/controlLayers/store/actions'; +import { canvasReset } from 'features/controlLayers/store/actions'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { modelChanged } from 'features/controlLayers/store/paramsSlice'; import { selectAllEntities, @@ -1806,13 +1803,7 @@ export const canvasSlice = createSlice({ syncScaledSize(state); } }); - builder.addCase(newSimpleCanvasSessionRequested, (state) => { - return resetState(state); - }); - builder.addCase(newAdvancedCanvasSessionRequested, (state) => { - const newState = resetState(state); - return newState; - }); + builder.addCase(canvasSessionStarted, (state) => resetState(state)); }, }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index 2dc441a801..4e840dcc35 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -1,11 +1,7 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { - canvasReset, - newAdvancedCanvasSessionRequested, - newSimpleCanvasSessionRequested, -} from 'features/controlLayers/store/actions'; +import { canvasReset } from 'features/controlLayers/store/actions'; import type { StagingAreaImage } from 'features/controlLayers/store/types'; import { selectCanvasQueueCounts } from 'services/api/endpoints/queue'; @@ -15,15 +11,17 @@ type CanvasStagingAreaState = { selectedImageIndex: number; }; -const initialState: CanvasStagingAreaState = { +const INITIAL_STATE: CanvasStagingAreaState = { sessionType: null, images: [], selectedImageIndex: 0, }; +const getInitialState = (): CanvasStagingAreaState => deepClone(INITIAL_STATE); + export const canvasSessionSlice = createSlice({ name: 'canvasSession', - initialState, + initialState: getInitialState(), reducers: { stagingAreaImageStaged: (state, action: PayloadAction<{ stagingAreaImage: StagingAreaImage }>) => { const { stagingAreaImage } = action.payload; @@ -49,25 +47,15 @@ export const canvasSessionSlice = createSlice({ state.images = []; state.selectedImageIndex = 0; }, - canvasSessionStarted: (state, action: PayloadAction<{ sessionType: CanvasStagingAreaState['sessionType'] }>) => { + canvasSessionStarted: (_, action: PayloadAction<{ sessionType: CanvasStagingAreaState['sessionType'] }>) => { const { sessionType } = action.payload; + const state = getInitialState(); state.sessionType = sessionType; - state.images = []; - state.selectedImageIndex = 0; + return state; }, }, extraReducers(builder) { - builder.addCase(canvasReset, () => deepClone(initialState)); - builder.addCase(newSimpleCanvasSessionRequested, () => { - const state = deepClone(initialState); - state.sessionType === 'simple'; - return state; - }); - builder.addCase(newAdvancedCanvasSessionRequested, () => { - const state = deepClone(initialState); - state.sessionType === 'advanced'; - return state; - }); + builder.addCase(canvasReset, () => getInitialState()); }, }); @@ -88,7 +76,7 @@ const migrate = (state: any): any => { export const canvasStagingAreaPersistConfig: PersistConfig = { name: canvasSessionSlice.name, - initialState, + initialState: getInitialState(), migrate, persistDenylist: [], }; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts index df94543507..e4d91cc2f2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts @@ -1,13 +1,12 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { LoRA } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import type { LoRAModelConfig } from 'services/api/types'; import { v4 as uuidv4 } from 'uuid'; -import { newSessionRequested } from './actions'; - type LoRAsState = { loras: LoRA[]; }; @@ -65,7 +64,7 @@ export const lorasSlice = createSlice({ }, }, extraReducers(builder) { - builder.addMatcher(newSessionRequested, () => { + builder.addCase(canvasSessionStarted, () => { // When a new session is requested, clear all LoRAs return deepClone(initialState); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index dabddf3c13..9fcf4a89f8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -1,6 +1,7 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ParamsState, RgbaColor } from 'features/controlLayers/store/types'; import { getInitialParamsState } from 'features/controlLayers/store/types'; import { CLIP_SKIP_MAP } from 'features/parameters/types/constants'; @@ -24,7 +25,6 @@ import { clamp } from 'lodash-es'; import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; import { isNonRefinerMainModelConfig } from 'services/api/types'; -import { newSessionRequested } from './actions'; export const paramsSlice = createSlice({ name: 'params', @@ -189,7 +189,7 @@ export const paramsSlice = createSlice({ paramsReset: (state) => resetState(state), }, extraReducers(builder) { - builder.addMatcher(newSessionRequested, (state) => resetState(state)); + builder.addCase(canvasSessionStarted, (state) => resetState(state)); }, }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 5e26c69311..c856099900 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -11,7 +11,7 @@ import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; -import { $hasProgressImage, $isProgressFromCanvas } from 'services/events/stores'; +import { $hasProgressImage } from 'services/events/stores'; import { NoContentForViewer } from './NoContentForViewer'; import ProgressImage from './ProgressImage'; @@ -87,10 +87,9 @@ export default memo(CurrentImagePreview); const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { const hasProgressImage = useStore($hasProgressImage); - const isProgressFromCanvas = useStore($isProgressFromCanvas); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); - if (hasProgressImage && !isProgressFromCanvas && shouldShowProgressInViewer) { + if (hasProgressImage && shouldShowProgressInViewer) { return ; } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx index d876ada415..f18c104fb2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx @@ -5,7 +5,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; import { selectSystemSlice } from 'features/system/store/systemSlice'; import { memo, useMemo } from 'react'; -import { $isProgressFromCanvas, $progressImage } from 'services/events/stores'; +import { $progressImage } from 'services/events/stores'; const selectShouldAntialiasProgressImage = createSelector( selectSystemSlice, @@ -14,7 +14,6 @@ const selectShouldAntialiasProgressImage = createSelector( const CurrentImagePreview = () => { const progressImage = useStore($progressImage); - const isProgressFromCanvas = useStore($isProgressFromCanvas); const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage); const sx = useMemo( @@ -24,7 +23,7 @@ const CurrentImagePreview = () => { [shouldAntialiasProgressImage] ); - if (!progressImage || isProgressFromCanvas) { + if (!progressImage) { return null; } diff --git a/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts b/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts index a16a729077..c9499ad613 100644 --- a/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts +++ b/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts @@ -2,7 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { newSessionRequested } from 'features/controlLayers/store/actions'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ParameterHRFMethod, ParameterStrength } from 'features/parameters/types/parameterSchemas'; interface HRFState { @@ -34,7 +34,7 @@ export const hrfSlice = createSlice({ }, }, extraReducers(builder) { - builder.addMatcher(newSessionRequested, () => { + builder.addCase(canvasSessionStarted, () => { return deepClone(initialHRFState); }); }, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXFill.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXFill.ts index 0c2ad4a1ce..c839bfbf18 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXFill.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXFill.ts @@ -119,9 +119,8 @@ export const addFLUXFill = async ({ }); g.addEdge(maskCombine, 'image', expandMask, 'mask'); - // Do the paste back if we are sending to gallery (in which case we want to see the full image), or if we are sending - // to canvas but not outputting only masked regions - if (!canvasSettings.sendToCanvas || !canvasSettings.outputOnlyMaskedRegions) { + // Do the paste back if we are not outputting only masked regions + if (!canvasSettings.outputOnlyMaskedRegions) { const imageLayerBlend = g.addNode({ type: 'invokeai_img_blend', id: getPrefixedId('image_layer_blend'), @@ -178,9 +177,8 @@ export const addFLUXFill = async ({ }); g.addEdge(maskCombine, 'image', expandMask, 'mask'); - // Do the paste back if we are sending to gallery (in which case we want to see the full image), or if we are sending - // to canvas but not outputting only masked regions - if (!canvasSettings.sendToCanvas || !canvasSettings.outputOnlyMaskedRegions) { + // Do the paste back if we are not outputting only masked regions + if (!canvasSettings.outputOnlyMaskedRegions) { const imageLayerBlend = g.addNode({ type: 'invokeai_img_blend', id: getPrefixedId('image_layer_blend'), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts index f2fce326d1..82191385a0 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts @@ -185,9 +185,8 @@ export const addInpaint = async ({ g.addEdge(expandMask, 'image', resizeMaskToOriginalSize, 'image'); // After denoising, resize the image and mask back to original size - // Do the paste back if we are sending to gallery (in which case we want to see the full image), or if we are sending - // to canvas but not outputting only masked regions - if (!canvasSettings.sendToCanvas || !canvasSettings.outputOnlyMaskedRegions) { + // Do the paste back if we are not outputting only masked regions + if (!canvasSettings.outputOnlyMaskedRegions) { const imageLayerBlend = g.addNode({ type: 'invokeai_img_blend', id: getPrefixedId('image_layer_blend'), @@ -259,9 +258,8 @@ export const addInpaint = async ({ }); g.addEdge(createGradientMask, 'expanded_mask_area', expandMask, 'mask'); - // Do the paste back if we are sending to gallery (in which case we want to see the full image), or if we are sending - // to canvas but not outputting only masked regions - if (!canvasSettings.sendToCanvas || !canvasSettings.outputOnlyMaskedRegions) { + // Do the paste back if we are not outputting only masked regions + if (!canvasSettings.outputOnlyMaskedRegions) { const imageLayerBlend = g.addNode({ type: 'invokeai_img_blend', id: getPrefixedId('image_layer_blend'), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts index 2c04288c9f..d02e6c6621 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts @@ -207,9 +207,9 @@ export const addOutpaint = async ({ g.addEdge(l2i, 'image', resizeOutputImageToOriginalSize, 'image'); g.addEdge(createGradientMask, 'expanded_mask_area', expandMask, 'mask'); g.addEdge(expandMask, 'image', resizeOutputMaskToOriginalSize, 'image'); - // Do the paste back if we are sending to gallery (in which case we want to see the full image), or if we are sending - // to canvas but not outputting only masked regions - if (!canvasSettings.sendToCanvas || !canvasSettings.outputOnlyMaskedRegions) { + + // Do the paste back if we are not outputting only masked regions + if (!canvasSettings.outputOnlyMaskedRegions) { const imageLayerBlend = g.addNode({ type: 'invokeai_img_blend', id: getPrefixedId('image_layer_blend'), @@ -295,9 +295,8 @@ export const addOutpaint = async ({ }); g.addEdge(createGradientMask, 'expanded_mask_area', expandMask, 'mask'); - // Do the paste back if we are sending to gallery (in which case we want to see the full image), or if we are sending - // to canvas but not outputting only masked regions - if (!canvasSettings.sendToCanvas || !canvasSettings.outputOnlyMaskedRegions) { + // Do the paste back if we are not outputting only masked regions + if (!canvasSettings.outputOnlyMaskedRegions) { const imageLayerBlend = g.addNode({ type: 'invokeai_img_blend', id: getPrefixedId('image_layer_blend'), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index dbad701bc9..7c6b0fa76d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -2,7 +2,6 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isChatGPT4oAspectRatioID, isChatGPT4oReferenceImageConfig } from 'features/controlLayers/store/types'; @@ -10,11 +9,7 @@ import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/va import { type ImageField, zModelIdentifierField } from 'features/nodes/types/common'; import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { - CANVAS_OUTPUT_PREFIX, - getBoardField, - selectPresetModifiedPrompts, -} from 'features/nodes/util/graph/graphBuilderUtils'; +import { CANVAS_OUTPUT_PREFIX, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; @@ -37,7 +32,6 @@ export const buildChatGPT4oGraph = async ( const model = selectMainModelConfig(state); const canvas = selectCanvasSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const { bbox } = canvas; const { positivePrompt } = selectPresetModifiedPrompts(state); @@ -65,9 +59,6 @@ export const buildChatGPT4oGraph = async ( } } - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - if (generationMode === 'txt2img') { const g = new Graph(getPrefixedId('chatgpt_4o_txt2img_graph')); const gptImage = g.addNode({ diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts index fb63235f9c..7b94605d9f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts @@ -2,7 +2,6 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers'; @@ -16,7 +15,6 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, - getBoardField, getSizes, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; @@ -36,7 +34,6 @@ export const buildCogView4Graph = async ( log.debug({ generationMode }, 'Building CogView4 graph'); const params = selectParamsSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const canvas = selectCanvasSlice(state); const { bbox } = canvas; @@ -176,13 +173,7 @@ export const buildCogView4Graph = async ( canvasOutput = addWatermarker(g, canvasOutput); } - // This image will be staged, should not be saved to the gallery or added to a board. - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - - if (!canvasSettings.sendToCanvas) { - g.upsertMetadata(selectCanvasMetadata(state)); - } + g.upsertMetadata(selectCanvasMetadata(state)); g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts index 136769b492..e024f852ed 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts @@ -2,7 +2,6 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addFLUXFill } from 'features/nodes/util/graph/generation/addFLUXFill'; @@ -19,7 +18,6 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, - getBoardField, getSizes, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; @@ -43,7 +41,6 @@ export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | log.debug({ generationMode }, 'Building FLUX graph'); const params = selectParamsSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const canvas = selectCanvasSlice(state); const { bbox } = canvas; @@ -335,13 +332,7 @@ export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | canvasOutput = addWatermarker(g, canvasOutput); } - // This image will be staged, should not be saved to the gallery or added to a board. - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - - if (!canvasSettings.sendToCanvas) { - g.upsertMetadata(selectCanvasMetadata(state)); - } + g.upsertMetadata(selectCanvasMetadata(state)); g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts index 6f5497a64a..ce1ecac591 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts @@ -2,18 +2,13 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; -import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isImagenAspectRatioID } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { - CANVAS_OUTPUT_PREFIX, - getBoardField, - selectPresetModifiedPrompts, -} from 'features/nodes/util/graph/graphBuilderUtils'; +import { CANVAS_OUTPUT_PREFIX, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; @@ -34,7 +29,6 @@ export const buildImagen3Graph = async ( log.debug({ generationMode }, 'Building Imagen3 graph'); const canvas = selectCanvasSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const { bbox } = canvas; const { positivePrompt, negativePrompt } = selectPresetModifiedPrompts(state); @@ -45,9 +39,6 @@ export const buildImagen3Graph = async ( assert(isImagenAspectRatioID(bbox.aspectRatio.id), 'Imagen3 does not support this aspect ratio'); assert(positivePrompt.length > 0, 'Imagen3 requires positive prompt to have at least one character'); - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - if (generationMode === 'txt2img') { const g = new Graph(getPrefixedId('imagen3_txt2img_graph')); const imagen3 = g.addNode({ @@ -70,6 +61,7 @@ export const buildImagen3Graph = async ( width: bbox.rect.width, height: bbox.rect.height, model: Graph.getModelMetadataField(model), + ...selectCanvasMetadata(state), }); return { g, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts index b5e866312d..ee499da7f8 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts @@ -2,18 +2,13 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; -import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isImagenAspectRatioID } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { - CANVAS_OUTPUT_PREFIX, - getBoardField, - selectPresetModifiedPrompts, -} from 'features/nodes/util/graph/graphBuilderUtils'; +import { CANVAS_OUTPUT_PREFIX, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; @@ -34,7 +29,6 @@ export const buildImagen4Graph = async ( log.debug({ generationMode }, 'Building Imagen4 graph'); const canvas = selectCanvasSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const { bbox } = canvas; const { positivePrompt, negativePrompt } = selectPresetModifiedPrompts(state); @@ -45,9 +39,6 @@ export const buildImagen4Graph = async ( assert(isImagenAspectRatioID(bbox.aspectRatio.id), 'Imagen4 does not support this aspect ratio'); assert(positivePrompt.length > 0, 'Imagen4 requires positive prompt to have at least one character'); - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - if (generationMode === 'txt2img') { const g = new Graph(getPrefixedId('imagen4_txt2img_graph')); const imagen4 = g.addNode({ @@ -70,7 +61,9 @@ export const buildImagen4Graph = async ( width: bbox.rect.width, height: bbox.rect.height, model: Graph.getModelMetadataField(model), + ...selectCanvasMetadata(state), }); + return { g, seedFieldIdentifier: { nodeId: imagen4.id, fieldName: 'seed' }, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index 7f869f69f8..fa1a85778c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -2,7 +2,6 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addControlNets, addT2IAdapters } from 'features/nodes/util/graph/generation/addControlAdapters'; @@ -20,7 +19,6 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, - getBoardField, getSizes, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; @@ -38,7 +36,6 @@ export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | log.debug({ generationMode }, 'Building SD1/SD2 graph'); const params = selectParamsSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const canvas = selectCanvasSlice(state); const { bbox } = canvas; @@ -307,13 +304,7 @@ export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | canvasOutput = addWatermarker(g, canvasOutput); } - // This image will be staged, should not be saved to the gallery or added to a board. - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - - if (!canvasSettings.sendToCanvas) { - g.upsertMetadata(selectCanvasMetadata(state)); - } + g.upsertMetadata(selectCanvasMetadata(state)); g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts index ab1acf7806..f6ba0fae60 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts @@ -2,8 +2,7 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectMainModelConfig,selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage'; import { addInpaint } from 'features/nodes/util/graph/generation/addInpaint'; @@ -15,7 +14,6 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, - getBoardField, getSizes, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; @@ -35,7 +33,6 @@ export const buildSD3Graph = async (state: RootState, manager?: CanvasManager | assert(model.base === 'sd-3'); const params = selectParamsSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const canvas = selectCanvasSlice(state); const { bbox } = canvas; @@ -197,13 +194,7 @@ export const buildSD3Graph = async (state: RootState, manager?: CanvasManager | canvasOutput = addWatermarker(g, canvasOutput); } - // This image will be staged, should not be saved to the gallery or added to a board. - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - - if (!canvasSettings.sendToCanvas) { - g.upsertMetadata(selectCanvasMetadata(state)); - } + g.upsertMetadata(selectCanvasMetadata(state)); g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index de19fe9151..9aaf3960c7 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -2,7 +2,6 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addControlNets, addT2IAdapters } from 'features/nodes/util/graph/generation/addControlAdapters'; @@ -20,7 +19,6 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { CANVAS_OUTPUT_PREFIX, - getBoardField, getSizes, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; @@ -42,7 +40,6 @@ export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | assert(model.base === 'sdxl'); const params = selectParamsSlice(state); - const canvasSettings = selectCanvasSettingsSlice(state); const canvas = selectCanvasSlice(state); const { bbox } = canvas; @@ -313,13 +310,7 @@ export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | canvasOutput = addWatermarker(g, canvasOutput); } - // This image will be staged, should not be saved to the gallery or added to a board. - const is_intermediate = canvasSettings.sendToCanvas; - const board = canvasSettings.sendToCanvas ? undefined : getBoardField(state); - - if (!canvasSettings.sendToCanvas) { - g.upsertMetadata(selectCanvasMetadata(state)); - } + g.upsertMetadata(selectCanvasMetadata(state)); g.updateNode(canvasOutput, { id: getPrefixedId(CANVAS_OUTPUT_PREFIX), diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx index bec8f2212a..68992a84f4 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx @@ -2,7 +2,6 @@ import type { TooltipProps } from '@invoke-ai/ui-library'; import { Divider, Flex, ListItem, Text, Tooltip, UnorderedList } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsSlice'; import { selectIterations } from 'features/controlLayers/store/paramsSlice'; import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; @@ -237,32 +236,14 @@ StyledDivider.displayName = 'StyledDivider'; const AddingToText = memo(() => { const { t } = useTranslation(); - const sendToCanvas = useAppSelector(selectSendToCanvas); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const autoAddBoardName = useBoardName(autoAddBoardId); - const addingTo = useMemo(() => { - if (sendToCanvas) { - return t('controlLayers.stagingOnCanvas'); - } - return t('parameters.invoke.addingImagesTo'); - }, [sendToCanvas, t]); - - const destination = useMemo(() => { - if (sendToCanvas) { - return t('queue.canvas'); - } - if (autoAddBoardName) { - return autoAddBoardName; - } - return t('boards.uncategorized'); - }, [autoAddBoardName, sendToCanvas, t]); - return ( - {addingTo}{' '} + {t('parameters.invoke.addingImagesTo')}{' '} - {destination} + {autoAddBoardName || t('boards.uncategorized')} ); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx b/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx index fc0172a117..d3f05cb30b 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx @@ -1,28 +1,17 @@ import { Flex, Spacer } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { ClearQueueIconButton } from 'features/queue/components/ClearQueueIconButton'; import { QueueActionsMenuButton } from 'features/queue/components/QueueActionsMenuButton'; -import { SendToToggle } from 'features/queue/components/SendToToggle'; import ProgressBar from 'features/system/components/ProgressBar'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo } from 'react'; import { InvokeButton } from './InvokeQueueBackButton'; const QueueControls = () => { - const tab = useAppSelector(selectActiveTab); - return ( - {tab === 'canvas' && ( - - - - )} diff --git a/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx b/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx deleted file mode 100644 index f4de748c91..0000000000 --- a/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { - Box, - Button, - chakra, - Flex, - Icon, - Popover, - PopoverArrow, - PopoverBody, - PopoverContent, - PopoverTrigger, - Portal, - Text, - useCheckbox, - useToken, -} from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectSendToCanvas, settingsSendToCanvasChanged } from 'features/controlLayers/store/canvasSettingsSlice'; -import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; -import type { ChangeEvent, PropsWithChildren } from 'react'; -import { memo, useCallback, useMemo } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { PiImageBold, PiPaintBrushBold } from 'react-icons/pi'; - -const getSx = (padding: string | number): SystemStyleObject => ({ - bg: 'base.700', - w: '72px', - cursor: 'pointer', - '&[data-checked]': { - '.thumb': { - left: `calc(100% - ${padding})`, - transform: 'translateX(-100%)', - bg: 'invokeGreen.300', - }, - '.unchecked-icon': { - color: 'base.50', - opacity: 0.4, - }, - '.checked-icon': { - color: 'base.900', - opacity: 1, - }, - }, - '&[data-disabled]': { - bg: 'base.700', - '.thumb': { - bg: 'base.500', - }, - '.unchecked-icon': { - color: 'base.800', - }, - '.checked-icon': { - color: 'base.800', - }, - }, - '.thumb': { - transition: 'left 0.1s ease-in-out, transform 0.1s ease-in-out', - left: padding, - transform: 'translateX(0)', - bg: 'invokeBlue.400', - shadow: 'md', - }, - '.unchecked-icon': { - color: 'base.900', - opacity: 1, - }, - '.checked-icon': { - color: 'base.50', - opacity: 0.4, - }, -}); - -export const SendToToggle = memo(() => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const sendToCanvas = useAppSelector(selectSendToCanvas); - const isStaging = useAppSelector(selectIsStaging); - - const gap = useToken('space', 1); - const sx = useMemo(() => getSx(gap), [gap]); - - const onChange = useCallback( - (e: ChangeEvent) => { - dispatch(settingsSendToCanvasChanged(e.target.checked)); - }, - [dispatch] - ); - - const { getCheckboxProps, getInputProps, htmlProps } = useCheckbox({ - onChange, - isChecked: sendToCanvas, - isDisabled: isStaging, - }); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - ); -}); - -SendToToggle.displayName = 'SendToToggle'; - -const TooltipContent = memo(() => { - const { t } = useTranslation(); - const sendToCanvas = useAppSelector(selectSendToCanvas); - const isStaging = useAppSelector(selectIsStaging); - - if (isStaging) { - return ( - - {t('controlLayers.sendingToCanvas')} - - }} /> - - - ); - } - - return ( - - - {sendToCanvas ? t('controlLayers.sendToCanvas') : t('controlLayers.sendToGallery')} - - - {sendToCanvas ? t('controlLayers.sendToCanvasDesc') : t('controlLayers.sendToGalleryDesc')} - - - ); -}); - -TooltipContent.displayName = 'TooltipContent'; - -const ActivateCanvasButton = memo((props: PropsWithChildren) => { - const dispatch = useAppDispatch(); - const onClick = useCallback(() => { - dispatch(setActiveTab('canvas')); - dispatch(activeTabCanvasRightPanelChanged('layers')); - $imageViewer.set(false); - }, [dispatch]); - return ( - - ); -}); - -ActivateCanvasButton.displayName = 'ActivateCanvasButton'; diff --git a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts index a0e6eb4002..efee56c9fe 100644 --- a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts +++ b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts @@ -2,7 +2,7 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { newSessionRequested } from 'features/controlLayers/store/actions'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { atom } from 'nanostores'; import { stylePresetsApi } from 'services/api/endpoints/stylePresets'; @@ -29,6 +29,9 @@ export const stylePresetSlice = createSlice({ }, }, extraReducers(builder) { + builder.addCase(canvasSessionStarted, () => { + return deepClone(initialState); + }); builder.addMatcher(stylePresetsApi.endpoints.deleteStylePreset.matchFulfilled, (state, action) => { if (state.activeStylePresetId === null) { return; @@ -47,9 +50,6 @@ export const stylePresetSlice = createSlice({ state.activeStylePresetId = null; } }); - builder.addMatcher(newSessionRequested, () => { - return deepClone(initialState); - }); }, }); diff --git a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts index eabcf5ae22..3c4bf462bc 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts @@ -1,7 +1,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { newSessionRequested } from 'features/controlLayers/store/actions'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { Dimensions } from 'features/controlLayers/store/types'; import { workflowLoaded } from 'features/nodes/store/nodesSlice'; import { atom } from 'nanostores'; @@ -56,7 +56,7 @@ export const uiSlice = createSlice({ builder.addCase(workflowLoaded, (state) => { state.activeTab = 'workflows'; }); - builder.addMatcher(newSessionRequested, (state) => { + builder.addCase(canvasSessionStarted, (state) => { state.activeTab = 'canvas'; }); }, diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 2748dd1e6d..92ad0aa011 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -1,10 +1,12 @@ import { logger } from 'app/logging/logger'; import type { AppDispatch, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; +import { stagingAreaImageStaged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; +import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils'; import type { ApiTagDescription } from 'services/api'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; @@ -163,26 +165,18 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A }; const handleOriginCanvas = async (data: S['InvocationCompleteEvent']) => { - const imageDTOs = await getResultImageDTOs(data); + if (!isCanvasOutputEvent(data)) { + return; + } // We expect only a single image in the canvas output - const imageDTO = imageDTOs[0]; + const imageDTO = (await getResultImageDTOs(data))[0]; + if (!imageDTO) { return; } - if (data.destination === 'canvas') { - // TODO(psyche): Can/should we let canvas handle this itself? - // if (isCanvasOutputEvent(data)) { - // if (data.result.type === 'image_output') { - // dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); - // } - // addImagesToGallery(data, [imageDTO]); - // } - } else if (!imageDTO.is_intermediate) { - // Desintaion is gallery - addImagesToGallery(data, [imageDTO]); - } + dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); }; const handleOriginOther = async (data: S['InvocationCompleteEvent']) => { diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index f46f3af079..d25a887c7d 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -8,21 +8,28 @@ export const $socket = atom(null); export const $socketOptions = map>({}); export const $isConnected = atom(false); export const $lastProgressEvent = atom(null); -export const $progressImage = computed($lastProgressEvent, (val) => val?.image ?? null); -export const $canvasProgressImage = computed($lastProgressEvent, (event) => { +$lastProgressEvent.subscribe((event) => { if (!event) { - return null; + return; } - if (event.origin !== 'canvas') { - return null; + switch (event.destination) { + case 'workflows': + $lastWorkflowsProgressEvent.set(event); + break; + case 'upscaling': + $lastUpscalingProgressEvent.set(event); + break; + case 'canvas': + $lastCanvasProgressEvent.set(event); + break; } - if (!event.image) { - return null; - } - return event.image; }); +export const $lastCanvasProgressEvent = atom(null); +export const $lastWorkflowsProgressEvent = atom(null); +export const $lastUpscalingProgressEvent = atom(null); + +export const $progressImage = computed($lastProgressEvent, (val) => val?.image ?? null); export const $hasProgressImage = computed($lastProgressEvent, (val) => Boolean(val?.image)); -export const $isProgressFromCanvas = computed($lastProgressEvent, (val) => val?.destination === 'canvas'); export const $invocationProgressMessage = computed($lastProgressEvent, (val) => { if (!val) { return null; From d985dfe82156f8174a62b8ddfe0f57d3fff995ce Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 27 May 2025 16:42:45 +1000 Subject: [PATCH 014/210] refactor(ui): canvas flow events (wip) --- .../components/CanvasMainPanelContent.tsx | 69 +++---------------- .../controlLayers/store/paramsSlice.ts | 5 -- .../services/events/onInvocationComplete.tsx | 9 ++- .../src/services/events/setEventListeners.tsx | 6 +- .../web/src/services/events/stores.ts | 16 +++++ 5 files changed, 38 insertions(+), 67 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index e17df9b425..e93587ef18 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -29,23 +29,17 @@ import { selectStagedImageIndex, selectStagedImages, stagingAreaImageSelected, - stagingAreaImageStaged, stagingAreaNextStagedImageSelected, stagingAreaPrevStagedImageSelected, } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { isImageField, type ProgressImage } from 'features/nodes/types/common'; -import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils'; -import type { Atom } from 'nanostores'; -import { atom } from 'nanostores'; -import { memo, useCallback, useEffect, useState } from 'react'; -import { flushSync } from 'react-dom'; +import type { ProgressImage } from 'features/nodes/types/common'; +import { memo, useCallback, useEffect } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; -import { getImageDTOSafe } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; -import { $socket } from 'services/events/stores'; +import { $lastCanvasProgressImage, $socket } from 'services/events/stores'; import type { Equals } from 'tsafe'; -import { assert, objectEntries } from 'tsafe'; +import { assert } from 'tsafe'; import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress'; @@ -131,53 +125,14 @@ const SimpleActiveSession = memo(() => { const dispatch = useAppDispatch(); const isStaging = useAppSelector(selectIsStaging); const socket = useStore($socket); - const [$progressImage] = useState(() => atom(null)); useEffect(() => { if (!socket) { return; } - const onInvocationProgress = (event: S['InvocationProgressEvent']) => { - if (!event) { - return; - } - if (event.origin !== 'canvas') { - return; - } - if (!event.image) { - return; - } - $progressImage.set({ sessionId: event.session_id, image: event.image }); - }; - const onInvocationComplete = async (event: S['InvocationCompleteEvent']) => { - const progressImage = $progressImage.get(); - if (!progressImage) { - return; - } - if (progressImage.sessionId !== event.session_id) { - return; - } - if (!isCanvasOutputEvent(event)) { - return; - } - let imageDTO: ImageDTO | null = null; - for (const [_name, value] of objectEntries(event.result)) { - if (isImageField(value)) { - imageDTO = await getImageDTOSafe(value.image_name); - break; - } - } - if (!imageDTO) { - return; - } - flushSync(() => { - dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); - }); - $progressImage.set(null); - }; const onQueueItemStatusChanged = (event: S['QueueItemStatusChangedEvent']) => { - const progressImage = $progressImage.get(); + const progressImage = $lastCanvasProgressImage.get(); if (!progressImage) { return; } @@ -187,20 +142,16 @@ const SimpleActiveSession = memo(() => { if (event.status !== 'canceled' && event.status !== 'failed') { return; } - $progressImage.set(null); + $lastCanvasProgressImage.set(null); }; console.log('SUB session preview image listeners'); - socket.on('invocation_progress', onInvocationProgress); - socket.on('invocation_complete', onInvocationComplete); socket.on('queue_item_status_changed', onQueueItemStatusChanged); return () => { console.log('UNSUB session preview image listeners'); - socket.off('invocation_progress', onInvocationProgress); - socket.off('invocation_complete', onInvocationComplete); socket.off('queue_item_status_changed', onQueueItemStatusChanged); }; - }, [$progressImage, dispatch, socket]); + }, [dispatch, socket]); const onReset = useCallback(() => { dispatch(canvasReset()); @@ -226,15 +177,15 @@ const SimpleActiveSession = memo(() => { - + ); }); SimpleActiveSession.displayName = 'SimpleActiveSession'; -const SelectedImage = memo(({ $progressImage }: { $progressImage: Atom }) => { - const progressImage = useStore($progressImage); +const SelectedImage = memo(() => { + const progressImage = useStore($lastCanvasProgressImage); const selectedImage = useAppSelector(selectSelectedImage); if (progressImage) { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 9fcf4a89f8..5fb816e709 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -1,7 +1,6 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ParamsState, RgbaColor } from 'features/controlLayers/store/types'; import { getInitialParamsState } from 'features/controlLayers/store/types'; import { CLIP_SKIP_MAP } from 'features/parameters/types/constants'; @@ -25,7 +24,6 @@ import { clamp } from 'lodash-es'; import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; import { isNonRefinerMainModelConfig } from 'services/api/types'; - export const paramsSlice = createSlice({ name: 'params', initialState: getInitialParamsState(), @@ -188,9 +186,6 @@ export const paramsSlice = createSlice({ }, paramsReset: (state) => resetState(state), }, - extraReducers(builder) { - builder.addCase(canvasSessionStarted, (state) => resetState(state)); - }, }); const resetState = (state: ParamsState): ParamsState => { diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 92ad0aa011..3c5f154950 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -7,12 +7,13 @@ import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils'; +import { flushSync } from 'react-dom'; import type { ApiTagDescription } from 'services/api'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; -import { $lastProgressEvent } from 'services/events/stores'; +import { $lastCanvasProgressImage, $lastProgressEvent } from 'services/events/stores'; import type { Param0 } from 'tsafe'; import { objectEntries } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -176,7 +177,11 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A return; } - dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); + flushSync(() => { + dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); + }); + + $lastCanvasProgressImage.set(null); }; const handleOriginOther = async (data: S['InvocationCompleteEvent']) => { diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 15b5f545ed..f41e9b5da6 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -30,7 +30,7 @@ import type { ClientToServerEvents, ServerToClientEvents } from 'services/events import type { Socket } from 'socket.io-client'; import type { JsonObject } from 'type-fest'; -import { $lastProgressEvent } from './stores'; +import { $lastCanvasProgressEvent, $lastProgressEvent } from './stores'; const log = logger('events'); @@ -428,6 +428,10 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis // If the queue item is completed, failed, or cancelled, we want to clear the last progress event $lastProgressEvent.set(null); + if (data.origin === 'canvas') { + $lastCanvasProgressEvent.set(null); + } + // When a validation run is completed, we want to clear the validation run batch ID & set the workflow as published const validationRunData = $validationRunData.get(); if (!validationRunData || batch_status.batch_id !== validationRunData.batchId || status !== 'completed') { diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index d25a887c7d..b724f246c7 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -1,3 +1,4 @@ +import type { ProgressImage } from 'features/nodes/types/common'; import { round } from 'lodash-es'; import { atom, computed, map } from 'nanostores'; import type { S } from 'services/api/types'; @@ -15,18 +16,33 @@ $lastProgressEvent.subscribe((event) => { switch (event.destination) { case 'workflows': $lastWorkflowsProgressEvent.set(event); + if (event.image) { + $lastWorkflowsProgressImage.set({ sessionId: event.session_id, image: event.image }); + } break; case 'upscaling': $lastUpscalingProgressEvent.set(event); + if (event.image) { + $lastUpscalingProgressImage.set({ sessionId: event.session_id, image: event.image }); + } break; case 'canvas': $lastCanvasProgressEvent.set(event); + if (event.image) { + $lastCanvasProgressImage.set({ sessionId: event.session_id, image: event.image }); + } break; } }); + +type EphemeralProgressImage = { sessionId: string; image: ProgressImage }; + export const $lastCanvasProgressEvent = atom(null); +export const $lastCanvasProgressImage = atom(null); export const $lastWorkflowsProgressEvent = atom(null); +export const $lastWorkflowsProgressImage = atom(null); export const $lastUpscalingProgressEvent = atom(null); +export const $lastUpscalingProgressImage = atom(null); export const $progressImage = computed($lastProgressEvent, (val) => val?.image ?? null); export const $hasProgressImage = computed($lastProgressEvent, (val) => Boolean(val?.image)); From faeb5f0c3b2dca49c21992b3eb2005d02cfc0e39 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 29 May 2025 16:36:36 +1000 Subject: [PATCH 015/210] refactor(ui): canvas flow (wip) --- invokeai/frontend/web/public/locales/en.json | 1 + invokeai/frontend/web/src/app/store/store.ts | 2 +- .../src/common/hooks/useImageUploadButton.tsx | 59 +++- .../CanvasAlertsInvocationProgress.tsx | 4 +- .../components/CanvasMainPanelContent.tsx | 223 +++++++++++++-- .../components/CanvasRightPanelStacked.tsx | 254 ++++++++++++++++++ .../IPAdapter/IPAdapterImagePreview.tsx | 4 +- .../NewSessionConfirmationAlertDialog.tsx | 2 +- ...edModelLoadingInvocationProgressMessage.ts | 4 +- .../src/features/controlLayers/store/types.ts | 3 + invokeai/frontend/web/src/features/dnd/dnd.ts | 30 +++ .../components/GalleryPanelContent.tsx | 44 ++- .../ImageViewer/CurrentImagePreview.tsx | 4 +- .../components/ImageViewer/ProgressImage.tsx | 4 +- .../web/src/features/imageActions/actions.ts | 42 ++- .../inputs/ImageFieldInputComponent.tsx | 4 +- .../UpscaleInitialImage.tsx | 4 +- .../src/features/ui/components/AppContent.tsx | 8 +- .../FloatingParametersPanelButtons.tsx | 4 +- .../src/services/events/setEventListeners.tsx | 32 ++- .../web/src/services/events/stores.ts | 35 +-- 21 files changed, 658 insertions(+), 109 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index ac12910bec..0375f0f456 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2018,6 +2018,7 @@ "replaceCurrent": "Replace Current", "controlLayerEmptyState": "Upload an image, drag an image from the gallery onto this layer, pull the bounding box into this layer, or draw on the canvas to get started.", "referenceImageEmptyState": "Upload an image, drag an image from the gallery onto this layer, or pull the bounding box into this layer to get started.", + "uploadOrDragAnImage": "Drag an image from the gallery or upload an image.", "imageNoise": "Image Noise", "denoiseLimit": "Denoise Limit", "warnings": { diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index a9f274c5cb..db02a6090b 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -8,8 +8,8 @@ import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice'; import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; import { canvasPersistConfig, canvasSlice, canvasUndoableConfig } from 'features/controlLayers/store/canvasSlice'; import { - canvasStagingAreaPersistConfig, canvasSessionSlice, + canvasStagingAreaPersistConfig, } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice'; import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice'; diff --git a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx index f4d88db6a0..93b3c50ce0 100644 --- a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx +++ b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx @@ -1,11 +1,11 @@ -import type { IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library'; -import { IconButton } from '@invoke-ai/ui-library'; +import type { ButtonProps, IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import { Button, IconButton } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; import { useAppSelector } from 'app/store/storeHooks'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice'; import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; +import { memo, useCallback } from 'react'; import type { FileRejection } from 'react-dropzone'; import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; @@ -163,32 +163,63 @@ const sx = { }, } satisfies SystemStyleObject; -export const UploadImageButton = ({ - isDisabled = false, - onUpload, - isError = false, - ...rest -}: { +export const UploadImageIconButton = memo( + ({ + isDisabled = false, + onUpload, + isError = false, + ...rest + }: { + onUpload?: (imageDTO: ImageDTO) => void; + isError?: boolean; + } & SetOptional) => { + const uploadApi = useImageUploadButton({ isDisabled, allowMultiple: false, onUpload }); + return ( + <> + } + isLoading={uploadApi.request.isLoading} + {...rest} + {...uploadApi.getUploadButtonProps()} + /> + + + ); + } +); +UploadImageIconButton.displayName = 'UploadImageIconButton'; + +type UploadImageButtonProps = { onUpload?: (imageDTO: ImageDTO) => void; isError?: boolean; -} & SetOptional) => { +} & ButtonProps; + +export const UploadImageButton = memo((props: UploadImageButtonProps) => { + const { children, isDisabled = false, onUpload, isError = false, ...rest } = props; const uploadApi = useImageUploadButton({ isDisabled, allowMultiple: false, onUpload }); return ( <> - } + rightIcon={} isLoading={uploadApi.request.isLoading} {...rest} {...uploadApi.getUploadButtonProps()} - /> + > + {children ?? 'Upload'} + ); -}; +}); +UploadImageButton.displayName = 'UploadImageButton'; export const UploadMultipleImageButton = ({ isDisabled = false, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress.tsx index 26a475469b..4e9dd5ec51 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress.tsx @@ -6,11 +6,11 @@ import { selectIsLocal } from 'features/system/store/configSlice'; import { selectSystemShouldShowInvocationProgressDetail } from 'features/system/store/systemSlice'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { $invocationProgressMessage } from 'services/events/stores'; +import { $lastProgressMessage } from 'services/events/stores'; const CanvasAlertsInvocationProgressContentLocal = memo(() => { const { t } = useTranslation(); - const invocationProgressMessage = useStore($invocationProgressMessage); + const invocationProgressMessage = useStore($lastProgressMessage); if (!invocationProgressMessage) { return null; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index e93587ef18..03f7844950 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,8 +1,21 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Button, ContextMenu, Flex, IconButton, Image, Menu, MenuButton, MenuList, Text } from '@invoke-ai/ui-library'; +import { + Button, + ContextMenu, + Flex, + Heading, + IconButton, + Image, + Menu, + MenuButton, + MenuList, + Text, +} from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; +import { useAppStore } from 'app/store/nanostores/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo'; @@ -32,13 +45,17 @@ import { stagingAreaNextStagedImageSelected, stagingAreaPrevStagedImageSelected, } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { newCanvasFromImage } from 'features/imageActions/actions'; import type { ProgressImage } from 'features/nodes/types/common'; -import { memo, useCallback, useEffect } from 'react'; +import { memo, useCallback, useEffect, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; +import { Trans, useTranslation } from 'react-i18next'; +import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; import type { ImageDTO, S } from 'services/api/types'; import { $lastCanvasProgressImage, $socket } from 'services/events/stores'; -import type { Equals } from 'tsafe'; +import type { Equals, Param0 } from 'tsafe'; import { assert } from 'tsafe'; import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress'; @@ -80,45 +97,190 @@ export const CanvasMainPanelContent = memo(() => { CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; +const generateWithStartingImageDndTargetData = newCanvasFromImageDndTarget.getData({ + type: 'raster_layer', + withResize: true, +}); +const generateWithStartingImageAndInpaintMaskDndTargetData = newCanvasFromImageDndTarget.getData({ + type: 'raster_layer', + withInpaintMask: true, +}); +const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getData({ + type: 'control_layer', + withResize: true, +}); + const NoActiveSession = memo(() => { + const { t } = useTranslation(); const dispatch = useAppDispatch(); const newSesh = useCallback(() => { dispatch(canvasSessionStarted({ sessionType: 'advanced' })); }, [dispatch]); + return ( - - No Active Session - - - - Generate with Starting Image - - New Canvas Session - - Dropped image as raster layer - - Bbox resized - - - Generate with Control Image - - New Canvas Session - - Dropped image as control layer - - Bbox resized - - - Edit Image - - New Canvas Session - - Dropped image as raster layer - - Bbox resized - - 1 Inpaint mask layer added + or + + + + ); }); NoActiveSession.displayName = 'NoActiveSession'; +const GenerateWithStartingImage = memo(() => { + const { t } = useTranslation(); + const { getState, dispatch } = useAppStore(); + const useImageUploadButtonOptions = useMemo>( + () => ({ + onUpload: (imageDTO: ImageDTO) => { + newCanvasFromImage({ imageDTO, type: 'raster_layer', withResize: true, getState, dispatch }); + }, + allowMultiple: false, + }), + [dispatch, getState] + ); + const uploadApi = useImageUploadButton(useImageUploadButtonOptions); + const components = useMemo( + () => ({ + UploadButton: ( + + + + + + + + @@ -343,7 +396,11 @@ const SelectedImage = memo(() => { ); } - return No images; + return ( + + No images + + ); }); SelectedImage.displayName = 'SelectedImage'; @@ -361,6 +418,8 @@ const SessionImages = memo(() => { }); SessionImages.displayName = 'SessionImages'; +const getStagingImageId = (imageDTO: ImageDTO) => `staging-image-${imageDTO.image_name}`; + const sx = { objectFit: 'contain', maxW: 'full', @@ -381,8 +440,14 @@ const SessionImage = memo(({ index, imageDTO }: { index: number; imageDTO: Image const onClick = useCallback(() => { dispatch(stagingAreaImageSelected({ index })); }, [dispatch, index]); + useEffect(() => { + if (selectedImageIndex === index) { + document.getElementById(getStagingImageId(imageDTO))?.scrollIntoView(); + } + }, [imageDTO, index, selectedImageIndex]); return ( { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); - const [element, ref] = useState(null); + const ref = useRef(null); const [dragPreviewState, setDragPreviewState] = useState(null); useEffect(() => { + const element = ref.current; if (!element) { return; } @@ -66,9 +67,9 @@ export const DndImage = memo(({ imageDTO, asThumbnail, ...rest }: DndImage.Props }, }) ); - }, [imageDTO, element, store]); + }, [imageDTO, store]); - useImageContextMenu(imageDTO, element); + useImageContextMenu(imageDTO, ref); return ( <> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx index 530269c5fb..b8676a2ad6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx @@ -7,6 +7,7 @@ import MultipleSelectionMenuItems from 'features/gallery/components/ImageContext import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems'; import { selectSelectionCount } from 'features/gallery/store/gallerySelectors'; import { map } from 'nanostores'; +import type { RefObject } from 'react'; import { memo, useCallback, useEffect, useRef } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -43,7 +44,7 @@ const onClose = () => { * Map of elements to image DTOs. This is used to determine which image DTO to show the context menu for, depending on * the target of the context menu or long press event. */ -const elToImageMap = new Map(); +const elToImageMap = new Map(); /** * Given a target node, find the first registered parent element that contains the target node and return the imageDTO @@ -59,17 +60,20 @@ const getImageDTOFromMap = (target: Node): ImageDTO | undefined => { * @param imageDTO The image DTO to register the context menu for. * @param targetRef The ref of the target element that should trigger the context menu. */ -export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: HTMLDivElement | null) => { +export const useImageContextMenu = (imageDTO: ImageDTO | undefined, ref: RefObject) => { useEffect(() => { - if (!targetRef || !imageDTO) { + if (!imageDTO) { + return; + } + const el = ref.current; + if (!el) { return; } - const el = targetRef; elToImageMap.set(el, imageDTO); return () => { elToImageMap.delete(el); }; - }, [imageDTO, targetRef]); + }, [imageDTO, ref]); }; /** diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 5721307fba..91e425c1f6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -19,7 +19,7 @@ import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/Sized import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { MouseEventHandler } from 'react'; -import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; // This class name is used to calculate the number of images that fit in the gallery @@ -89,7 +89,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { const [dragPreviewState, setDragPreviewState] = useState< DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null >(null); - const [element, ref] = useState(null); + const ref = useRef(null); const dndId = useId(); const selectIsSelectedForCompare = useMemo( () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name), @@ -111,6 +111,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { const isSelected = useAppSelector(selectIsSelected); useEffect(() => { + const element = ref.current; if (!element) { return; } @@ -175,7 +176,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { }, }) ); - }, [imageDTO, element, store, dndId]); + }, [imageDTO, store, dndId]); const [isHovered, setIsHovered] = useState(false); @@ -211,7 +212,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]); - useImageContextMenu(imageDTO, element); + useImageContextMenu(imageDTO, ref); return ( <> From 650809e50dd33459a62235544b19636b38110349 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:13:38 +1000 Subject: [PATCH 018/210] feat(ui): images always added to gallery in simple session --- .../graph/generation/buildChatGPT4oGraph.ts | 12 +++------- .../graph/generation/buildCogView4Graph.ts | 9 ++----- .../util/graph/generation/buildFLUXGraph.ts | 9 ++----- .../graph/generation/buildImagen3Graph.ts | 9 +++---- .../graph/generation/buildImagen4Graph.ts | 9 +++---- .../util/graph/generation/buildSD1Graph.ts | 9 ++----- .../util/graph/generation/buildSD3Graph.ts | 9 ++----- .../util/graph/generation/buildSDXLGraph.ts | 9 ++----- .../nodes/util/graph/graphBuilderUtils.ts | 24 +++++++++++++++++++ 9 files changed, 43 insertions(+), 56 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index 7c6b0fa76d..e6a4791587 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -9,7 +9,7 @@ import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/va import { type ImageField, zModelIdentifierField } from 'features/nodes/types/common'; import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { CANVAS_OUTPUT_PREFIX, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; +import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; @@ -64,14 +64,11 @@ export const buildChatGPT4oGraph = async ( const gptImage = g.addNode({ // @ts-expect-error: These nodes are not available in the OSS application type: 'chatgpt_4o_generate_image', - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), model: zModelIdentifierField.parse(model), positive_prompt: positivePrompt, aspect_ratio: bbox.aspectRatio.id, reference_images, - use_cache: false, - is_intermediate: true, - board: undefined, + ...selectCanvasOutputFields(state), }); g.upsertMetadata({ positive_prompt: positivePrompt, @@ -96,15 +93,12 @@ export const buildChatGPT4oGraph = async ( const gptImage = g.addNode({ // @ts-expect-error: These nodes are not available in the OSS application type: 'chatgpt_4o_edit_image', - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), model: zModelIdentifierField.parse(model), positive_prompt: positivePrompt, aspect_ratio: bbox.aspectRatio.id, base_image: { image_name }, reference_images, - use_cache: false, - is_intermediate: true, - board: undefined, + ...selectCanvasOutputFields(state), }); g.upsertMetadata({ positive_prompt: positivePrompt, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts index 7b94605d9f..4e369bc3bf 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts @@ -14,8 +14,8 @@ import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermar import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { - CANVAS_OUTPUT_PREFIX, getSizes, + selectCanvasOutputFields, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; @@ -175,12 +175,7 @@ export const buildCogView4Graph = async ( g.upsertMetadata(selectCanvasMetadata(state)); - g.updateNode(canvasOutput, { - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate: true, - use_cache: false, - board: undefined, - }); + g.updateNode(canvasOutput, selectCanvasOutputFields(state)); g.setMetadataReceivingNode(canvasOutput); return { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts index e024f852ed..2bbfe1d37e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts @@ -17,8 +17,8 @@ import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermar import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { - CANVAS_OUTPUT_PREFIX, getSizes, + selectCanvasOutputFields, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import { @@ -334,12 +334,7 @@ export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | g.upsertMetadata(selectCanvasMetadata(state)); - g.updateNode(canvasOutput, { - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate: true, - use_cache: false, - board: undefined, - }); + g.updateNode(canvasOutput, selectCanvasOutputFields(state)); g.setMetadataReceivingNode(canvasOutput); return { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts index ce1ecac591..b200d6b915 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts @@ -8,7 +8,7 @@ import { isImagenAspectRatioID } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { CANVAS_OUTPUT_PREFIX, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; +import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; @@ -44,16 +44,13 @@ export const buildImagen3Graph = async ( const imagen3 = g.addNode({ // @ts-expect-error: These nodes are not available in the OSS application type: 'google_imagen3_generate_image', - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), model: zModelIdentifierField.parse(model), positive_prompt: positivePrompt, negative_prompt: negativePrompt, aspect_ratio: bbox.aspectRatio.id, - enhance_prompt: true, // When enhance_prompt is true, Imagen3 will return a new image every time, ignoring the seed. - use_cache: false, - is_intermediate: true, - board: undefined, + enhance_prompt: true, + ...selectCanvasOutputFields(state), }); g.upsertMetadata({ positive_prompt: positivePrompt, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts index ee499da7f8..5138041c9e 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts @@ -8,7 +8,7 @@ import { isImagenAspectRatioID } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { CANVAS_OUTPUT_PREFIX, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; +import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; @@ -44,16 +44,13 @@ export const buildImagen4Graph = async ( const imagen4 = g.addNode({ // @ts-expect-error: These nodes are not available in the OSS application type: 'google_imagen4_generate_image', - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), model: zModelIdentifierField.parse(model), positive_prompt: positivePrompt, negative_prompt: negativePrompt, aspect_ratio: bbox.aspectRatio.id, - enhance_prompt: true, // When enhance_prompt is true, Imagen4 will return a new image every time, ignoring the seed. - use_cache: false, - is_intermediate: true, - board: undefined, + enhance_prompt: true, + ...selectCanvasOutputFields(state), }); g.upsertMetadata({ positive_prompt: positivePrompt, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index fa1a85778c..0bf32670b4 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -18,8 +18,8 @@ import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermar import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { - CANVAS_OUTPUT_PREFIX, getSizes, + selectCanvasOutputFields, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; @@ -306,12 +306,7 @@ export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | g.upsertMetadata(selectCanvasMetadata(state)); - g.updateNode(canvasOutput, { - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate: true, - use_cache: false, - board: undefined, - }); + g.updateNode(canvasOutput, selectCanvasOutputFields(state)); g.setMetadataReceivingNode(canvasOutput); return { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts index f6ba0fae60..3230d7132c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts @@ -13,8 +13,8 @@ import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermar import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { - CANVAS_OUTPUT_PREFIX, getSizes, + selectCanvasOutputFields, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; @@ -196,12 +196,7 @@ export const buildSD3Graph = async (state: RootState, manager?: CanvasManager | g.upsertMetadata(selectCanvasMetadata(state)); - g.updateNode(canvasOutput, { - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate: true, - use_cache: false, - board: undefined, - }); + g.updateNode(canvasOutput, selectCanvasOutputFields(state)); g.setMetadataReceivingNode(canvasOutput); return { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index 9aaf3960c7..1960b6ab8b 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -18,8 +18,8 @@ import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermar import { getGenerationMode } from 'features/nodes/util/graph/generation/getGenerationMode'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { - CANVAS_OUTPUT_PREFIX, getSizes, + selectCanvasOutputFields, selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; @@ -312,12 +312,7 @@ export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | g.upsertMetadata(selectCanvasMetadata(state)); - g.updateNode(canvasOutput, { - id: getPrefixedId(CANVAS_OUTPUT_PREFIX), - is_intermediate: true, - use_cache: false, - board: undefined, - }); + g.updateNode(canvasOutput, selectCanvasOutputFields(state)); g.setMetadataReceivingNode(canvasOutput); return { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts index 1163c462ad..dcce4be8b2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts @@ -1,5 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import type { CanvasState, ParamsState } from 'features/controlLayers/store/types'; import type { BoardField } from 'features/nodes/types/common'; @@ -24,6 +26,28 @@ export const getBoardField = (state: RootState): BoardField | undefined => { return { board_id: autoAddBoardId }; }; +/** + * Builds the common fields for canvas output: + * - id + * - use_cache + * - is_intermediate + * - board + */ +export const selectCanvasOutputFields = (state: RootState) => { + // Advanced session means working on canvas - images are not saved to gallery or added to a board. + // Simple session means working in YOLO mode - images are saved to gallery & board. + const sessionType = selectCanvasSessionType(state); + const is_intermediate = sessionType === 'advanced'; + const board = sessionType === 'advanced' ? undefined : getBoardField(state); + + return { + is_intermediate, + board, + use_cache: false, + id: getPrefixedId(CANVAS_OUTPUT_PREFIX), + }; +}; + /** * Gets the prompts, modified for the active style preset. */ From b3f3020793718c8b2bba05003f772978911ada58 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:29:43 +1000 Subject: [PATCH 019/210] fix(ui): ensure images are added to gallery in simple sessions --- .../src/services/events/onInvocationComplete.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 3c5f154950..d191becfde 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -23,12 +23,17 @@ const log = logger('events'); const nodeTypeDenylist = ['load_image', 'image']; export const buildOnInvocationComplete = (getState: () => RootState, dispatch: AppDispatch) => { - const addImagesToGallery = (data: S['InvocationCompleteEvent'], imageDTOs: ImageDTO[]) => { + const addImagesToGallery = async (data: S['InvocationCompleteEvent']) => { if (nodeTypeDenylist.includes(data.invocation.type)) { log.trace(`Skipping denylisted node type (${data.invocation.type})`); return; } + const imageDTOs = await getResultImageDTOs(data); + if (imageDTOs.length === 0) { + return; + } + // For efficiency's sake, we want to minimize the number of dispatches and invalidations we do. // We'll keep track of each change we need to make and do them all at once. const boardTotalAdditions: Record = {}; @@ -161,8 +166,7 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A upsertExecutionState(nes.nodeId, nes); } - const imageDTOs = await getResultImageDTOs(data); - addImagesToGallery(data, imageDTOs); + await addImagesToGallery(data); }; const handleOriginCanvas = async (data: S['InvocationCompleteEvent']) => { @@ -170,6 +174,8 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A return; } + await addImagesToGallery(data); + // We expect only a single image in the canvas output const imageDTO = (await getResultImageDTOs(data))[0]; @@ -185,8 +191,7 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A }; const handleOriginOther = async (data: S['InvocationCompleteEvent']) => { - const imageDTOs = await getResultImageDTOs(data); - addImagesToGallery(data, imageDTOs); + await addImagesToGallery(data); }; return async (data: S['InvocationCompleteEvent']) => { From 7a5fa25b48b368eead5217f75d85d61b91916110 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:30:06 +1000 Subject: [PATCH 020/210] feat(ui): support bookmarking an entity when adding it --- .../controlLayers/store/canvasSlice.ts | 67 +++++++++++++++---- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 1b135b0ce4..d3181c7e9c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -109,10 +109,11 @@ export const canvasSlice = createSlice({ id: string; overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }> ) => { - const { id, overrides, isSelected, mergedEntitiesToDelete = [] } = action.payload; + const { id, overrides, isSelected, isBookmarked, mergedEntitiesToDelete = [] } = action.payload; const entityState = getRasterLayerState(id, overrides); state.rasterLayers.entities.push(entityState); @@ -123,13 +124,20 @@ export const canvasSlice = createSlice({ ); } + const entityIdentifier = getEntityIdentifier(entityState); + if (isSelected || mergedEntitiesToDelete.length > 0) { - state.selectedEntityIdentifier = getEntityIdentifier(entityState); + state.selectedEntityIdentifier = entityIdentifier; + } + + if (isBookmarked) { + state.bookmarkedEntityIdentifier = entityIdentifier; } }, prepare: (payload: { overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }) => ({ payload: { ...payload, id: getPrefixedId('raster_layer') }, @@ -262,10 +270,11 @@ export const canvasSlice = createSlice({ id: string; overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }> ) => { - const { id, overrides, isSelected, mergedEntitiesToDelete = [] } = action.payload; + const { id, overrides, isSelected, isBookmarked, mergedEntitiesToDelete = [] } = action.payload; const entityState = getControlLayerState(id, overrides); @@ -276,14 +285,20 @@ export const canvasSlice = createSlice({ (entity) => !mergedEntitiesToDelete.includes(entity.id) ); } + const entityIdentifier = getEntityIdentifier(entityState); if (isSelected || mergedEntitiesToDelete.length > 0) { - state.selectedEntityIdentifier = getEntityIdentifier(entityState); + state.selectedEntityIdentifier = entityIdentifier; + } + + if (isBookmarked) { + state.bookmarkedEntityIdentifier = entityIdentifier; } }, prepare: (payload: { overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }) => ({ payload: { ...payload, id: getPrefixedId('control_layer') }, @@ -549,18 +564,32 @@ export const canvasSlice = createSlice({ referenceImageAdded: { reducer: ( state, - action: PayloadAction<{ id: string; overrides?: Partial; isSelected?: boolean }> + action: PayloadAction<{ + id: string; + overrides?: Partial; + isSelected?: boolean; + isBookmarked?: boolean; + }> ) => { - const { id, overrides, isSelected } = action.payload; + const { id, overrides, isSelected, isBookmarked } = action.payload; const entityState = getReferenceImageState(id, overrides); state.referenceImages.entities.push(entityState); + const entityIdentifier = getEntityIdentifier(entityState); if (isSelected) { - state.selectedEntityIdentifier = getEntityIdentifier(entityState); + state.selectedEntityIdentifier = entityIdentifier; + } + + if (isBookmarked) { + state.bookmarkedEntityIdentifier = entityIdentifier; } }, - prepare: (payload?: { overrides?: Partial; isSelected?: boolean }) => ({ + prepare: (payload?: { + overrides?: Partial; + isSelected?: boolean; + isBookmarked?: boolean; + }) => ({ payload: { ...payload, id: getPrefixedId('reference_image') }, }), }, @@ -737,10 +766,11 @@ export const canvasSlice = createSlice({ id: string; overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }> ) => { - const { id, overrides, isSelected, mergedEntitiesToDelete = [] } = action.payload; + const { id, overrides, isSelected, isBookmarked, mergedEntitiesToDelete = [] } = action.payload; const entityState = getRegionalGuidanceState(id, overrides); @@ -751,14 +781,20 @@ export const canvasSlice = createSlice({ (entity) => !mergedEntitiesToDelete.includes(entity.id) ); } + const entityIdentifier = getEntityIdentifier(entityState); if (isSelected || mergedEntitiesToDelete.length > 0) { - state.selectedEntityIdentifier = getEntityIdentifier(entityState); + state.selectedEntityIdentifier = entityIdentifier; + } + + if (isBookmarked) { + state.bookmarkedEntityIdentifier = entityIdentifier; } }, prepare: (payload?: { overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }) => ({ payload: { ...payload, id: getPrefixedId('regional_guidance') }, @@ -1038,10 +1074,11 @@ export const canvasSlice = createSlice({ id: string; overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }> ) => { - const { id, overrides, isSelected, mergedEntitiesToDelete = [] } = action.payload; + const { id, overrides, isSelected, isBookmarked, mergedEntitiesToDelete = [] } = action.payload; const entityState = getInpaintMaskState(id, overrides); @@ -1052,14 +1089,20 @@ export const canvasSlice = createSlice({ (entity) => !mergedEntitiesToDelete.includes(entity.id) ); } + const entityIdentifier = getEntityIdentifier(entityState); if (isSelected || mergedEntitiesToDelete.length > 0) { - state.selectedEntityIdentifier = getEntityIdentifier(entityState); + state.selectedEntityIdentifier = entityIdentifier; + } + + if (isBookmarked) { + state.bookmarkedEntityIdentifier = entityIdentifier; } }, prepare: (payload?: { overrides?: Partial; isSelected?: boolean; + isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; }) => ({ payload: { ...payload, id: getPrefixedId('inpaint_mask') }, From 2a92524546a380c6c3a81a77f3866bbb28bb005b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:30:24 +1000 Subject: [PATCH 021/210] feat(ui): bookmark new inpaint masks --- .../web/src/features/imageActions/actions.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index 99497ca928..d432433903 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -199,7 +199,7 @@ export const newCanvasFromImage = async (arg: { dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(rasterLayerAdded({ overrides, isSelected: true })); if (withInpaintMask) { - dispatch(inpaintMaskAdded({ isSelected: true })); + dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); } dispatch(canvasClearHistory()); break; @@ -216,7 +216,7 @@ export const newCanvasFromImage = async (arg: { dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(controlLayerAdded({ overrides, isSelected: true })); if (withInpaintMask) { - dispatch(inpaintMaskAdded({ isSelected: true })); + dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); } dispatch(canvasClearHistory()); break; @@ -232,7 +232,7 @@ export const newCanvasFromImage = async (arg: { dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(inpaintMaskAdded({ overrides, isSelected: true })); if (withInpaintMask) { - dispatch(inpaintMaskAdded({ isSelected: true })); + dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); } dispatch(canvasClearHistory()); break; @@ -248,7 +248,7 @@ export const newCanvasFromImage = async (arg: { dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(rgAdded({ overrides, isSelected: true })); if (withInpaintMask) { - dispatch(inpaintMaskAdded({ isSelected: true })); + dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); } dispatch(canvasClearHistory()); break; @@ -259,7 +259,7 @@ export const newCanvasFromImage = async (arg: { dispatch(canvasSessionStarted({ sessionType: 'advanced' })); dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true })); if (withInpaintMask) { - dispatch(inpaintMaskAdded({ isSelected: true })); + dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); } dispatch(canvasClearHistory()); break; @@ -271,7 +271,7 @@ export const newCanvasFromImage = async (arg: { dispatch(canvasSessionStarted({ sessionType: 'advanced' })); dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true })); if (withInpaintMask) { - dispatch(inpaintMaskAdded({ isSelected: true })); + dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); } dispatch(canvasClearHistory()); break; From 57bfae6774282cb392c00142ab063717b4714957 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:30:56 +1000 Subject: [PATCH 022/210] fix(ui): ensure all args are passed to handler when creating new canvas from image --- invokeai/frontend/web/src/features/dnd/dnd.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index deacaa47ab..89abd63a77 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -368,9 +368,8 @@ export const newCanvasFromImageDndTarget: DndTarget { - const { type, withResize } = targetData.payload; const { imageDTO } = sourceData.payload; - newCanvasFromImage({ type, imageDTO, dispatch, getState, withResize }); + newCanvasFromImage({ imageDTO, dispatch, getState, ...targetData.payload }); }, }; //#endregion From 579318af703f203b2dac462140e53e4f3084dbe8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:31:06 +1000 Subject: [PATCH 023/210] fix(ui): remove unused sessionId field from type --- .../frontend/web/src/features/controlLayers/store/types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index b69cb9ec0c..02e1ff739c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -1,7 +1,7 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; import { fetchModelConfigByIdentifier } from 'features/metadata/util/modelFetchingHelpers'; -import type { ProgressImage} from 'features/nodes/types/common'; +import type { ProgressImage } from 'features/nodes/types/common'; import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import type { ParameterLoRAModel } from 'features/parameters/types/parameterSchemas'; import { @@ -438,7 +438,6 @@ export type LoRA = { }; export type StagingAreaImage = { - sessionId: string; imageDTO: ImageDTO; offsetX: number; offsetY: number; From 1446d3490b0291d2d05785c2bf3c6ef0e7c59d2b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:31:43 +1000 Subject: [PATCH 024/210] fix(ui): merge refs when forwardingin DndImage --- .../web/src/features/dnd/DndImage.tsx | 125 +++++++++--------- .../ImageContextMenu/ImageContextMenu.tsx | 5 +- 2 files changed, 63 insertions(+), 67 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index ded8ab249e..9009fe440f 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -9,7 +9,7 @@ import { createSingleImageDragPreview, setSingleImageDragPreview } from 'feature import { firefoxDndFix } from 'features/dnd/util'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { memo, useEffect, useRef, useState } from 'react'; +import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; const sx = { @@ -23,69 +23,68 @@ const sx = { }, } satisfies SystemStyleObject; -/* eslint-disable-next-line @typescript-eslint/no-namespace */ -export namespace DndImage { - export interface Props extends ImageProps { - imageDTO: ImageDTO; - asThumbnail?: boolean; - } -} +type Props = { + imageDTO: ImageDTO; + asThumbnail?: boolean; +} & ImageProps; -export const DndImage = memo(({ imageDTO, asThumbnail, ...rest }: DndImage.Props) => { - const store = useAppStore(); - const [isDragging, setIsDragging] = useState(false); - const ref = useRef(null); - const [dragPreviewState, setDragPreviewState] = useState(null); +export const DndImage = memo( + forwardRef(({ imageDTO, asThumbnail, ...rest }: Props, forwardedRef) => { + const store = useAppStore(); + const [isDragging, setIsDragging] = useState(false); + const ref = useRef(null); + useImperativeHandle(forwardedRef, () => ref.current!, []); + const [dragPreviewState, setDragPreviewState] = useState(null); - useEffect(() => { - const element = ref.current; - if (!element) { - return; - } - return combine( - firefoxDndFix(element), - draggable({ - element, - getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name), - onDragStart: () => { - setIsDragging(true); - if ($imageViewer.get()) { - $imageViewer.set(false); - } - }, - onDrop: () => { - setIsDragging(false); - }, - onGenerateDragPreview: (args) => { - if (singleImageDndSource.typeGuard(args.source.data)) { - setSingleImageDragPreview({ - singleImageDndData: args.source.data, - onGenerateDragPreviewArgs: args, - setDragPreviewState, - }); - } - }, - }) + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return combine( + firefoxDndFix(element), + draggable({ + element, + getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name), + onDragStart: () => { + setIsDragging(true); + if ($imageViewer.get()) { + $imageViewer.set(false); + } + }, + onDrop: () => { + setIsDragging(false); + }, + onGenerateDragPreview: (args) => { + if (singleImageDndSource.typeGuard(args.source.data)) { + setSingleImageDragPreview({ + singleImageDndData: args.source.data, + onGenerateDragPreviewArgs: args, + setDragPreviewState, + }); + } + }, + }) + ); + }, [forwardedRef, imageDTO, store]); + + useImageContextMenu(imageDTO, ref); + + return ( + <> + + {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} + ); - }, [imageDTO, store]); - - useImageContextMenu(imageDTO, ref); - - return ( - <> - - {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} - - ); -}); - + }) +); DndImage.displayName = 'DndImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx index b8676a2ad6..d944d3dac0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx @@ -60,11 +60,8 @@ const getImageDTOFromMap = (target: Node): ImageDTO | undefined => { * @param imageDTO The image DTO to register the context menu for. * @param targetRef The ref of the target element that should trigger the context menu. */ -export const useImageContextMenu = (imageDTO: ImageDTO | undefined, ref: RefObject) => { +export const useImageContextMenu = (imageDTO: ImageDTO, ref: RefObject) => { useEffect(() => { - if (!imageDTO) { - return; - } const el = ref.current; if (!el) { return; From eb45a457e98b48836805043052acf4b0a9d1e7e7 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:32:44 +1000 Subject: [PATCH 025/210] fix(ui): ref goes undefined in GalleryImage This appears to be a bug in Chakra UI v2 - use of a fallback component makes the ref passed to an image end up undefined. Had to remove the skeleton loader fallback component. --- .../src/features/gallery/components/ImageGrid/GalleryImage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 91e425c1f6..87ae975857 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -15,7 +15,6 @@ import { firefoxDndFix } from 'features/dnd/util'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; -import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader'; import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { MouseEventHandler } from 'react'; @@ -235,7 +234,6 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { } w={imageDTO.width} objectFit="contain" maxW="full" From a3851e0b0822fe94b5bb1e9c946130ab35b92e83 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 30 May 2025 21:57:42 +1000 Subject: [PATCH 026/210] refactor(ui): canvas flow (wip) --- .../components/CanvasMainPanelContent.tsx | 161 +++++++++--------- 1 file changed, 85 insertions(+), 76 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index af2a5db6c0..a70ed1e200 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,6 +1,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, + ButtonGroup, ContextMenu, Flex, Heading, @@ -33,12 +34,10 @@ import { StagingAreaToolbar } from 'features/controlLayers/components/StagingAre import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { canvasReset } from 'features/controlLayers/store/actions'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; import { canvasSessionStarted, selectCanvasSessionType, - selectIsStaging, selectSelectedImage, selectStagedImageIndex, selectStagedImages, @@ -46,6 +45,7 @@ import { stagingAreaNextStagedImageSelected, stagingAreaPrevStagedImageSelected, } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import type { EphemeralProgressImage } from 'features/controlLayers/store/types'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; @@ -286,10 +286,12 @@ const SimpleActiveSession = memo(() => { const { getState, dispatch } = useAppStore(); const selectedImage = useAppSelector(selectSelectedImage); - const isStaging = useAppSelector(selectIsStaging); + const startOver = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: null })); + }, [dispatch]); - const onReset = useCallback(() => { - dispatch(canvasReset()); + const goAdvanced = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: 'advanced' })); }, [dispatch]); const selectNext = useCallback(() => { @@ -304,96 +306,34 @@ const SimpleActiveSession = memo(() => { useHotkeys(['left'], selectPrev, { preventDefault: true }, [selectPrev]); - const vary = useCallback(() => { - if (!selectedImage) { - return; - } - newCanvasFromImage({ - imageDTO: selectedImage.imageDTO, - type: 'raster_layer', - withResize: true, - getState, - dispatch, - }); - }, [dispatch, getState, selectedImage]); - - const useAsControl = useCallback(() => { - if (!selectedImage) { - return; - } - newCanvasFromImage({ - imageDTO: selectedImage.imageDTO, - type: 'control_layer', - withResize: true, - getState, - dispatch, - }); - }, [dispatch, getState, selectedImage]); - - const edit = useCallback(() => { - if (!selectedImage) { - return; - } - newCanvasFromImage({ - imageDTO: selectedImage.imageDTO, - type: 'raster_layer', - withInpaintMask: true, - getState, - dispatch, - }); - }, [dispatch, getState, selectedImage]); - return ( - + - Simple Session (staging view) {isStaging && 'STAGING'} + Generations from this Session - - - - - - - + ); }); SimpleActiveSession.displayName = 'SimpleActiveSession'; -const SelectedImage = memo(() => { +const SelectedImageOrProgressImage = memo(() => { const progressImage = useStore($lastCanvasProgressImage); const selectedImage = useAppSelector(selectSelectedImage); if (progressImage) { - return ( - - - - ); + return ; } if (selectedImage) { - return ( - - - - ); + return ; } return ( @@ -402,12 +342,80 @@ const SelectedImage = memo(() => { ); }); +SelectedImageOrProgressImage.displayName = 'SelectedImageOrProgressImage'; + +const SelectedImage = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { + const { getState, dispatch } = useAppStore(); + + const vary = useCallback(() => { + newCanvasFromImage({ + imageDTO, + type: 'raster_layer', + withResize: true, + getState, + dispatch, + }); + }, [dispatch, getState, imageDTO]); + + const useAsControl = useCallback(() => { + newCanvasFromImage({ + imageDTO, + type: 'control_layer', + withResize: true, + getState, + dispatch, + }); + }, [dispatch, getState, imageDTO]); + + const edit = useCallback(() => { + newCanvasFromImage({ + imageDTO, + type: 'raster_layer', + withInpaintMask: true, + getState, + dispatch, + }); + }, [dispatch, getState, imageDTO]); + return ( + + + + + + + + + + + ); +}); SelectedImage.displayName = 'SelectedImage'; +const ProgressImage = memo(({ progressImage }: { progressImage: EphemeralProgressImage }) => { + return ( + + + + ); +}); +ProgressImage.displayName = 'ProgressImage'; + const SessionImages = memo(() => { const stagedImages = useAppSelector(selectStagedImages); return ( - + {stagedImages.map(({ imageDTO }, index) => ( @@ -442,6 +450,7 @@ const SessionImage = memo(({ index, imageDTO }: { index: number; imageDTO: Image }, [dispatch, index]); useEffect(() => { if (selectedImageIndex === index) { + // this doesn't work when the DndImage is in a popover... why document.getElementById(getStagingImageId(imageDTO))?.scrollIntoView(); } }, [imageDTO, index, selectedImageIndex]); From 5e93f585300ca9db400f2bd60190d8ce5f332c80 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 31 May 2025 00:01:57 +1000 Subject: [PATCH 027/210] wip progress events --- invokeai/frontend/web/src/app/store/store.ts | 2 + .../components/CanvasMainPanelContent.tsx | 125 ++++++++++++++---- .../store/canvasStagingAreaSlice.ts | 30 ++++- .../src/features/controlLayers/store/types.ts | 6 + .../services/events/onInvocationComplete.tsx | 19 ++- .../src/services/events/setEventListeners.tsx | 25 +++- .../web/src/services/events/stores.ts | 27 +++- 7 files changed, 192 insertions(+), 42 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index db02a6090b..0a2f515334 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -3,6 +3,7 @@ import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/too import { logger } from 'app/logging/logger'; import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver'; import { errorHandler } from 'app/store/enhancers/reduxRemember/errors'; +import { getDebugLoggerMiddleware } from 'app/store/middleware/debugLoggerMiddleware'; import { deepClone } from 'common/util/deepClone'; import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice'; import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; @@ -175,6 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) + .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index a70ed1e200..7c61000b89 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,5 +1,6 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { + Box, Button, ButtonGroup, ContextMenu, @@ -40,12 +41,10 @@ import { selectCanvasSessionType, selectSelectedImage, selectStagedImageIndex, - selectStagedImages, stagingAreaImageSelected, stagingAreaNextStagedImageSelected, stagingAreaPrevStagedImageSelected, } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import type { EphemeralProgressImage } from 'features/controlLayers/store/types'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; @@ -55,7 +54,8 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; -import { $lastCanvasProgressImage } from 'services/events/stores'; +import type { ProgressAndResult } from 'services/events/stores'; +import { $progressImages, useMapSelector } from 'services/events/stores'; import type { Equals, Param0 } from 'tsafe'; import { assert } from 'tsafe'; @@ -288,6 +288,7 @@ const SimpleActiveSession = memo(() => { const startOver = useCallback(() => { dispatch(canvasSessionStarted({ sessionType: null })); + $progressImages.set({}); }, [dispatch]); const goAdvanced = useCallback(() => { @@ -325,15 +326,10 @@ const SimpleActiveSession = memo(() => { SimpleActiveSession.displayName = 'SimpleActiveSession'; const SelectedImageOrProgressImage = memo(() => { - const progressImage = useStore($lastCanvasProgressImage); const selectedImage = useAppSelector(selectSelectedImage); - if (progressImage) { - return ; - } - if (selectedImage) { - return ; + return ; } return ( @@ -397,36 +393,107 @@ const SelectedImage = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { }); SelectedImage.displayName = 'SelectedImage'; -const ProgressImage = memo(({ progressImage }: { progressImage: EphemeralProgressImage }) => { +const FullSizeImage = memo(({ sessionId }: { sessionId: string }) => { + const _progressImage = useMapSelector(sessionId, $progressImages); + + if (!_progressImage) { + return ( + + Pending + + ); + } + + if (_progressImage.resultImage) { + return ; + } + + if (_progressImage.progressImage) { + return ( + + + + ); + } + return ( - + No progress yet ); }); -ProgressImage.displayName = 'ProgressImage'; +FullSizeImage.displayName = 'FullSizeImage'; const SessionImages = memo(() => { - const stagedImages = useAppSelector(selectStagedImages); + const progressImages = useStore($progressImages); return ( - {stagedImages.map(({ imageDTO }, index) => ( - - ))} + {Object.values(progressImages).map((data, index) => { + if (data.type === 'staged') { + return ; + } else { + return ; + } + })} ); }); SessionImages.displayName = 'SessionImages'; -const getStagingImageId = (imageDTO: ImageDTO) => `staging-image-${imageDTO.image_name}`; +const ProgressImagePreview = ({ index, data }: { index: number; data: ProgressAndResult }) => { + const dispatch = useAppDispatch(); + const selectedImageIndex = useAppSelector(selectStagedImageIndex); + const onClick = useCallback(() => { + dispatch(stagingAreaImageSelected({ index })); + }, [dispatch, index]); + + useEffect(() => { + if (selectedImageIndex === index) { + // this doesn't work when the DndImage is in a popover... why + document.getElementById(getStagingImageId(data.sessionId))?.scrollIntoView(); + } + }, [data.sessionId, index, selectedImageIndex]); + + if (data.resultImage) { + return ( + + ); + } + + if (data.progressImage) { + return ( + + ); + } + + return ; +}; + +const getStagingImageId = (session_id: string) => `staging-image-${session_id}`; const sx = { objectFit: 'contain', @@ -442,7 +509,7 @@ const sx = { opacity: 0.5, }, } satisfies SystemStyleObject; -const SessionImage = memo(({ index, imageDTO }: { index: number; imageDTO: ImageDTO }) => { +const SessionImage = memo(({ index, data }: { index: number; data: ProgressAndResult }) => { const dispatch = useAppDispatch(); const selectedImageIndex = useAppSelector(selectStagedImageIndex); const onClick = useCallback(() => { @@ -451,17 +518,19 @@ const SessionImage = memo(({ index, imageDTO }: { index: number; imageDTO: Image useEffect(() => { if (selectedImageIndex === index) { // this doesn't work when the DndImage is in a popover... why - document.getElementById(getStagingImageId(imageDTO))?.scrollIntoView(); + document.getElementById(getStagingImageId(data.sessionId))?.scrollIntoView(); } - }, [imageDTO, index, selectedImageIndex]); + }, [data.sessionId, index, selectedImageIndex]); return ( ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index 4e840dcc35..2703286e72 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -2,12 +2,12 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolki import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; import { canvasReset } from 'features/controlLayers/store/actions'; -import type { StagingAreaImage } from 'features/controlLayers/store/types'; +import type { StagingAreaImage, StagingAreaProgressImage } from 'features/controlLayers/store/types'; import { selectCanvasQueueCounts } from 'services/api/endpoints/queue'; type CanvasStagingAreaState = { sessionType: 'simple' | 'advanced' | null; - images: StagingAreaImage[]; + images: (StagingAreaImage | StagingAreaProgressImage)[]; selectedImageIndex: number; }; @@ -25,8 +25,28 @@ export const canvasSessionSlice = createSlice({ reducers: { stagingAreaImageStaged: (state, action: PayloadAction<{ stagingAreaImage: StagingAreaImage }>) => { const { stagingAreaImage } = action.payload; - state.images.push(stagingAreaImage); - state.selectedImageIndex = state.images.length - 1; + let didReplace = false; + const newImages = []; + for (const i of state.images) { + if (i.sessionId === stagingAreaImage.sessionId) { + newImages.push(stagingAreaImage); + didReplace = true; + } else { + newImages.push(i); + } + } + if (!didReplace) { + newImages.push(stagingAreaImage); + } + state.images = newImages; + }, + stagingAreaGenerationStarted: (state, action: PayloadAction<{ sessionId: string }>) => { + const { sessionId } = action.payload; + state.images.push({ type: 'progress', sessionId }); + }, + stagingAreaGenerationFinished: (state, action: PayloadAction<{ sessionId: string }>) => { + const { sessionId } = action.payload; + state.images = state.images.filter((data) => data.sessionId !== sessionId); }, stagingAreaImageSelected: (state, action: PayloadAction<{ index: number }>) => { const { index } = action.payload; @@ -61,6 +81,8 @@ export const canvasSessionSlice = createSlice({ export const { stagingAreaImageStaged, + stagingAreaGenerationStarted, + stagingAreaGenerationFinished, stagingAreaStagedImageDiscarded, stagingAreaReset, stagingAreaImageSelected, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 02e1ff739c..070547f246 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -438,10 +438,16 @@ export type LoRA = { }; export type StagingAreaImage = { + type: 'staged'; + sessionId: string; imageDTO: ImageDTO; offsetX: number; offsetY: number; }; +export type StagingAreaProgressImage = { + type: 'progress'; + sessionId: string; +}; export type EphemeralProgressImage = { sessionId: string; image: ProgressImage }; export const zAspectRatioID = z.enum(['Free', '21:9', '9:21', '16:9', '3:2', '4:3', '1:1', '3:4', '2:3', '9:16']); diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index d191becfde..bcd51ca191 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -13,7 +13,11 @@ import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; -import { $lastCanvasProgressImage, $lastProgressEvent } from 'services/events/stores'; +import { + $lastCanvasProgressImage, + $lastProgressEvent, + $progressImages, +} from 'services/events/stores'; import type { Param0 } from 'tsafe'; import { objectEntries } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -184,9 +188,20 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A } flushSync(() => { - dispatch(stagingAreaImageStaged({ stagingAreaImage: { imageDTO, offsetX: 0, offsetY: 0 } })); + dispatch( + stagingAreaImageStaged({ + stagingAreaImage: { type: 'staged', sessionId: data.session_id, imageDTO, offsetX: 0, offsetY: 0 }, + }) + ); }); + const progressData = $progressImages.get()[data.session_id]; + if (progressData) { + $progressImages.setKey(data.session_id, { ...progressData, isFinished: true, resultImage: imageDTO }); + } else { + $progressImages.setKey(data.session_id, { sessionId: data.session_id, isFinished: true, resultImage: imageDTO }); + } + $lastCanvasProgressImage.set(null); }; diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index febd966442..1a873f5d43 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -8,6 +8,9 @@ import { $bulkDownloadId } from 'app/store/nanostores/bulkDownloadId'; import { $queueId } from 'app/store/nanostores/queueId'; import type { AppStore } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; +import { + stagingAreaGenerationStarted, +} from 'features/controlLayers/store/canvasStagingAreaSlice'; import { $isInPublishFlow, $outputNodeId, @@ -38,6 +41,7 @@ import { $lastUpscalingProgressImage, $lastWorkflowsProgressEvent, $lastWorkflowsProgressImage, + $progressImages, } from './stores'; const log = logger('events'); @@ -115,6 +119,15 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis $lastProgressEvent.set(data); + if (data.image) { + const progressData = $progressImages.get()[session_id]; + if (progressData) { + $progressImages.setKey(session_id, { ...progressData, progressImage: data.image }); + } else { + $progressImages.setKey(session_id, { sessionId: session_id, isFinished: false, progressImage: data.image }); + } + } + if (origin === 'canvas') { $lastCanvasProgressEvent.set(data); if (image) { @@ -432,6 +445,10 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis clone.outputs = []; $nodeExecutionStates.setKey(clone.nodeId, clone); }); + if (data.origin === 'canvas') { + store.dispatch(stagingAreaGenerationStarted({ sessionId: session_id })); + $progressImages.setKey(session_id, { sessionId: session_id, isFinished: false }); + } } else if (status === 'completed' || status === 'failed' || status === 'canceled') { if (status === 'failed' && error_type) { const isLocal = getState().config.isLocal ?? true; @@ -455,13 +472,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis } // If the queue item is completed, failed, or cancelled, we want to clear the last progress event $lastProgressEvent.set(null); - - if (data.origin === 'canvas') { - $lastCanvasProgressEvent.set(null); - if (status === 'canceled' || status === 'failed') { - $lastCanvasProgressImage.set(null); - } - } + $progressImages.setKey(session_id, undefined); // When a validation run is completed, we want to clear the validation run batch ID & set the workflow as published const validationRunData = $validationRunData.get(); diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index 63f020d52b..e233e4fea1 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -1,7 +1,10 @@ import type { EphemeralProgressImage } from 'features/controlLayers/store/types'; +import type { ProgressImage } from 'features/nodes/types/common'; import { round } from 'lodash-es'; +import type { MapStore } from 'nanostores'; import { atom, computed, map } from 'nanostores'; -import type { S } from 'services/api/types'; +import { useEffect, useState } from 'react'; +import type { ImageDTO, S } from 'services/api/types'; import type { AppSocket } from 'services/events/types'; import type { ManagerOptions, SocketOptions } from 'socket.io-client'; @@ -10,6 +13,28 @@ export const $socketOptions = map>({}); export const $isConnected = atom(false); export const $lastProgressEvent = atom(null); +export type ProgressAndResult = { + sessionId: string; + isFinished: boolean; + progressImage?: ProgressImage; + resultImage?: ImageDTO; +}; +export const $progressImages = map({} as Record); + +export const useMapSelector = (id: string, map: MapStore>): T | undefined => { + const [value, setValue] = useState(); + useEffect(() => { + const unsub = map.subscribe((data) => { + setValue(data[id]); + }); + return () => { + unsub(); + }; + }, [id, map]); + + return value; +}; + export const $lastCanvasProgressEvent = atom(null); export const $lastCanvasProgressImage = atom(null); export const $lastWorkflowsProgressEvent = atom(null); From 8a78e37634e57baf514fefa8946612a4eda34bca Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:45:01 +1000 Subject: [PATCH 028/210] feat: canvas flow rework (wip) --- invokeai/app/api/routers/session_queue.py | 36 +- .../session_queue/session_queue_base.py | 14 +- .../session_queue/session_queue_common.py | 37 +- .../session_queue/session_queue_sqlite.py | 67 ++- invokeai/app/services/shared/graph.py | 17 + invokeai/frontend/web/src/app/store/store.ts | 3 +- .../components/CanvasMainPanelContent.tsx | 417 +++++++++++++++++- .../nodes/util/graph/graphBuilderUtils.ts | 4 +- .../QueueList/QueueItemComponent.tsx | 6 +- .../components/QueueList/QueueItemDetail.tsx | 12 +- .../queue/components/QueueList/QueueList.tsx | 10 +- .../QueueList/QueueListComponent.tsx | 4 +- .../QueueList/useDestinationText.ts | 4 +- .../components/QueueList/useOriginText.ts | 4 +- .../web/src/services/api/endpoints/queue.ts | 17 +- .../frontend/web/src/services/api/schema.ts | 217 ++++----- .../frontend/web/src/services/api/types.ts | 1 - .../src/services/events/setEventListeners.tsx | 43 +- 18 files changed, 645 insertions(+), 268 deletions(-) diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index fe0c31bce9..d21610fed3 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -20,7 +20,6 @@ from invokeai.app.services.session_queue.session_queue_common import ( RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, - SessionQueueItemDTO, SessionQueueStatus, ) from invokeai.app.services.shared.pagination import CursorPaginatedResults @@ -68,7 +67,7 @@ async def enqueue_batch( "/{queue_id}/list", operation_id="list_queue_items", responses={ - 200: {"model": CursorPaginatedResults[SessionQueueItemDTO]}, + 200: {"model": CursorPaginatedResults[SessionQueueItem]}, }, ) async def list_queue_items( @@ -77,11 +76,38 @@ async def list_queue_items( status: Optional[QUEUE_ITEM_STATUS] = Query(default=None, description="The status of items to fetch"), cursor: Optional[int] = Query(default=None, description="The pagination cursor"), priority: int = Query(default=0, description="The pagination cursor priority"), -) -> CursorPaginatedResults[SessionQueueItemDTO]: - """Gets all queue items (without graphs)""" + destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), +) -> CursorPaginatedResults[SessionQueueItem]: + """Gets cursor-paginated queue items""" return ApiDependencies.invoker.services.session_queue.list_queue_items( - queue_id=queue_id, limit=limit, status=status, cursor=cursor, priority=priority + queue_id=queue_id, + limit=limit, + status=status, + cursor=cursor, + priority=priority, + destination=destination, + ) + + +@session_queue_router.get( + "/{queue_id}/all", + operation_id="list_all_queue_items", + responses={ + 200: {"model": list[SessionQueueItem]}, + }, +) +async def list_all_queue_items( + queue_id: str = Path(description="The queue id to perform this operation on"), + status: Optional[QUEUE_ITEM_STATUS] = Query(default=None, description="The status of items to fetch"), + destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), +) -> list[SessionQueueItem]: + """Gets all queue items""" + + return ApiDependencies.invoker.services.session_queue.list_all_queue_items( + queue_id=queue_id, + status=status, + destination=destination, ) diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 39dac82e23..187fa2e815 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -17,7 +17,6 @@ from invokeai.app.services.session_queue.session_queue_common import ( RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, - SessionQueueItemDTO, SessionQueueStatus, ) from invokeai.app.services.shared.graph import GraphExecutionState @@ -127,10 +126,21 @@ class SessionQueueBase(ABC): priority: int, cursor: Optional[int] = None, status: Optional[QUEUE_ITEM_STATUS] = None, - ) -> CursorPaginatedResults[SessionQueueItemDTO]: + destination: Optional[str] = None, + ) -> CursorPaginatedResults[SessionQueueItem]: """Gets a page of session queue items""" pass + @abstractmethod + def list_all_queue_items( + self, + queue_id: str, + status: Optional[QUEUE_ITEM_STATUS] = None, + destination: Optional[str] = None, + ) -> list[SessionQueueItem]: + """Gets all queue items that match the given parameters""" + pass + @abstractmethod def get_queue_item(self, item_id: int) -> SessionQueueItem: """Gets a session queue item by ID""" diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index fafb07b49f..1861110c97 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -208,7 +208,7 @@ class FieldIdentifier(BaseModel): user_label: str | None = Field(description="The user label of the field, if any") -class SessionQueueItemWithoutGraph(BaseModel): +class SessionQueueItem(BaseModel): """Session queue item without the full graph. Used for serialization.""" item_id: int = Field(description="The identifier of the session queue item") @@ -252,42 +252,7 @@ class SessionQueueItemWithoutGraph(BaseModel): default=None, description="The ID of the published workflow associated with this queue item", ) - api_input_fields: Optional[list[FieldIdentifier]] = Field( - default=None, description="The fields that were used as input to the API" - ) - api_output_fields: Optional[list[FieldIdentifier]] = Field( - default=None, description="The nodes that were used as output from the API" - ) credits: Optional[float] = Field(default=None, description="The total credits used for this queue item") - - @classmethod - def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO": - # must parse these manually - queue_item_dict["field_values"] = get_field_values(queue_item_dict) - return SessionQueueItemDTO(**queue_item_dict) - - model_config = ConfigDict( - json_schema_extra={ - "required": [ - "item_id", - "status", - "batch_id", - "queue_id", - "session_id", - "priority", - "session_id", - "created_at", - "updated_at", - ] - } - ) - - -class SessionQueueItemDTO(SessionQueueItemWithoutGraph): - pass - - -class SessionQueueItem(SessionQueueItemWithoutGraph): session: GraphExecutionState = Field(description="The fully-populated session to be executed") workflow: Optional[WorkflowWithoutID] = Field( default=None, description="The workflow associated with this queue item" diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 3249441329..81239338ec 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -24,7 +24,6 @@ from invokeai.app.services.session_queue.session_queue_common import ( RetryItemsResult, SessionQueueCountsByDestination, SessionQueueItem, - SessionQueueItemDTO, SessionQueueItemNotFoundError, SessionQueueStatus, ValueToInsertTuple, @@ -540,26 +539,12 @@ class SqliteSessionQueue(SessionQueueBase): priority: int, cursor: Optional[int] = None, status: Optional[QUEUE_ITEM_STATUS] = None, - ) -> CursorPaginatedResults[SessionQueueItemDTO]: + destination: Optional[str] = None, + ) -> CursorPaginatedResults[SessionQueueItem]: cursor_ = self._conn.cursor() item_id = cursor query = """--sql - SELECT item_id, - status, - priority, - field_values, - error_type, - error_message, - error_traceback, - created_at, - updated_at, - completed_at, - started_at, - session_id, - batch_id, - queue_id, - origin, - destination + SELECT * FROM session_queue WHERE queue_id = ? """ @@ -571,6 +556,12 @@ class SqliteSessionQueue(SessionQueueBase): """ params.append(status) + if destination is not None: + query += """---sql + AND destination = ? + """ + params.append(destination) + if item_id is not None: query += """--sql AND (priority < ?) OR (priority = ? AND item_id > ?) @@ -586,7 +577,7 @@ class SqliteSessionQueue(SessionQueueBase): params.append(limit + 1) cursor_.execute(query, params) results = cast(list[sqlite3.Row], cursor_.fetchall()) - items = [SessionQueueItemDTO.queue_item_dto_from_dict(dict(result)) for result in results] + items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results] has_more = False if len(items) > limit: # remove the extra item @@ -594,6 +585,44 @@ class SqliteSessionQueue(SessionQueueBase): has_more = True return CursorPaginatedResults(items=items, limit=limit, has_more=has_more) + def list_all_queue_items( + self, + queue_id: str, + status: Optional[QUEUE_ITEM_STATUS] = None, + destination: Optional[str] = None, + ) -> list[SessionQueueItem]: + """Gets all queue items that match the given parameters""" + cursor_ = self._conn.cursor() + query = """--sql + SELECT * + FROM session_queue + WHERE queue_id = ? + """ + params: list[Union[str, int]] = [queue_id] + + if status is not None: + query += """--sql + AND status = ? + """ + params.append(status) + + if destination is not None: + query += """---sql + AND destination = ? + """ + params.append(destination) + + query += """--sql + ORDER BY + priority DESC, + item_id ASC + ; + """ + cursor_.execute(query, params) + results = cast(list[sqlite3.Row], cursor_.fetchall()) + items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results] + return items + def get_queue_status(self, queue_id: str) -> SessionQueueStatus: cursor = self._conn.cursor() cursor.execute( diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index 9f22f2c6da..2706a0936a 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -7,6 +7,7 @@ from typing import Any, Optional, TypeVar, Union, get_args, get_origin, get_type import networkx as nx from pydantic import ( BaseModel, + ConfigDict, GetCoreSchemaHandler, GetJsonSchemaHandler, ValidationError, @@ -787,6 +788,22 @@ class GraphExecutionState(BaseModel): default_factory=dict, ) + model_config = ConfigDict( + json_schema_extra={ + "required": [ + "id", + "graph", + "execution_graph", + "executed", + "executed_history", + "results", + "errors", + "prepared_source_mapping", + "source_prepared_mapping", + ] + } + ) + @field_validator("graph") def graph_is_valid(cls, v: Graph): """Validates that the graph is valid""" diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 0a2f515334..a65416924d 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -3,7 +3,6 @@ import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/too import { logger } from 'app/logging/logger'; import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver'; import { errorHandler } from 'app/store/enhancers/reduxRemember/errors'; -import { getDebugLoggerMiddleware } from 'app/store/middleware/debugLoggerMiddleware'; import { deepClone } from 'common/util/deepClone'; import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice'; import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; @@ -176,7 +175,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) - .concat(getDebugLoggerMiddleware()) + // .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 7c61000b89..8f853f5d09 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,10 +1,14 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; +/* eslint-disable i18next/no-literal-string */ +import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Button, ButtonGroup, + CircularProgress, ContextMenu, Flex, + FormControl, + FormLabel, Heading, IconButton, Image, @@ -12,9 +16,13 @@ import { MenuButton, MenuList, Spacer, + Switch, Text, + Tooltip, } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; @@ -49,15 +57,21 @@ import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { newCanvasFromImage } from 'features/imageActions/actions'; -import { memo, useCallback, useEffect, useMemo } from 'react'; +import type { ProgressImage } from 'features/nodes/types/common'; +import { isImageField } from 'features/nodes/types/common'; +import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; -import type { ImageDTO } from 'services/api/types'; +import { getImageDTOSafe, useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue'; +import type { ImageDTO, S } from 'services/api/types'; import type { ProgressAndResult } from 'services/events/stores'; -import { $progressImages, useMapSelector } from 'services/events/stores'; +import { $progressImages, $socket, useMapSelector } from 'services/events/stores'; import type { Equals, Param0 } from 'tsafe'; -import { assert } from 'tsafe'; +import { assert, objectEntries } from 'tsafe'; import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress'; @@ -311,20 +325,405 @@ const SimpleActiveSession = memo(() => { - Generations from this Session + Generations - - - + ); }); SimpleActiveSession.displayName = 'SimpleActiveSession'; +const scrollIndicatorSx = { + opacity: 0, + '&[data-visible="true"]': { + opacity: 1, + }, +} satisfies SystemStyleObject; + +const StagingArea = memo(() => { + const [selectedItemId, setSelectedItemId] = useState(null); + const [autoSwitch, setAutoSwitch] = useState(true); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const scrollableRef = useRef(null); + const { data } = useListQueueItemsQuery({ destination: 'canvas' }); + const items = useMemo(() => { + if (!data) { + return EMPTY_ARRAY; + } + return queueItemsAdapterSelectors.selectAll(data); + }, [data]); + const selectedItem = useMemo( + () => + data && selectedItemId !== null ? queueItemsAdapterSelectors.selectById(data, String(selectedItemId)) : null, + [data, selectedItemId] + ); + + useEffect(() => { + if (items.length === 0) { + setSelectedItemId(null); + return; + } + if (selectedItem === null && items.length > 0) { + setSelectedItemId(items[0]?.item_id ?? null); + return; + } + if (selectedItemId === null || items.find((item) => item.item_id === selectedItemId) === undefined) { + return; + } + document.getElementById(`queue-item-status-card-${selectedItemId}`)?.scrollIntoView(); + }, [items, selectedItem, selectedItemId]); + + useEffect(() => { + const el = scrollableRef.current; + if (!el) { + return; + } + const onScroll = () => { + const { scrollLeft, scrollWidth, clientWidth } = el; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft + clientWidth < scrollWidth); + }; + el.addEventListener('scroll', onScroll); + const observer = new ResizeObserver(onScroll); + observer.observe(el); + return () => { + el.removeEventListener('scroll', onScroll); + observer.disconnect(); + }; + }, []); + + const onSelectItem = useCallback((item: S['SessionQueueItem']) => { + setSelectedItemId(item.item_id); + if (item.status !== 'in_progress') { + setAutoSwitch(false); + } + }, []); + + const onNext = useCallback(() => { + if (selectedItemId === null) { + return; + } + const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); + const nextIndex = (currentIndex + 1) % items.length; + const nextItem = items[nextIndex]; + if (!nextItem) { + return; + } + setSelectedItemId(nextItem.item_id); + }, [items, selectedItemId]); + const onPrev = useCallback(() => { + if (selectedItemId === null) { + return; + } + const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); + const prevIndex = (currentIndex - 1 + items.length) % items.length; + const prevItem = items[prevIndex]; + if (!prevItem) { + return; + } + setSelectedItemId(prevItem.item_id); + }, [items, selectedItemId]); + + useHotkeys('left', onPrev); + useHotkeys('right', onNext); + + const socket = useStore($socket); + useEffect(() => { + if (!autoSwitch) { + return; + } + + if (!socket) { + return; + } + + const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { + if (data.destination !== 'canvas') { + return; + } + if (data.status === 'in_progress') { + setSelectedItemId(data.item_id); + } + }; + + socket.on('queue_item_status_changed', onQueueItemStatusChanged); + + return () => { + socket.off('queue_item_status_changed', onQueueItemStatusChanged); + }; + }, [autoSwitch, socket]); + + const onChangeAutoSwitch = useCallback((e: ChangeEvent) => { + setAutoSwitch(e.target.checked); + }, []); + + return ( + + + {selectedItem && } + {!selectedItem && No queued generations} + + + Auto-switch + + + + + {items.map((item, i) => ( + + ))} + + + + + + ); +}); +StagingArea.displayName = 'StagingArea'; + +const IMAGE_DTO_ERROR = Symbol('IMAGE_DTO_ERROR'); + +const useOutputImageDTO = (item: S['SessionQueueItem']) => { + const [imageDTO, setImageDTO] = useState(null); + const syncImageDTO = useCallback(async (item: S['SessionQueueItem']) => { + const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => + isCanvasOutputNodeId(nodeId) + )?.[1][0]; + const output = nodeId ? item.session.results[nodeId] : undefined; + + if (!output) { + return setImageDTO(null); + } + + for (const [_name, value] of objectEntries(output)) { + if (isImageField(value)) { + const imageDTO = await getImageDTOSafe(value.image_name); + if (imageDTO) { + setImageDTO(imageDTO); + $progressImages.setKey(item.session_id, undefined); + return; + } + } + } + + setImageDTO(IMAGE_DTO_ERROR); + }, []); + useEffect(() => { + syncImageDTO(item); + }, [item, syncImageDTO]); + + return imageDTO; +}; + +const QueueItemStatusCard = memo( + ({ + item, + isSelected, + number, + onSelectItem, + ...rest + }: { + item: S['SessionQueueItem']; + isSelected: boolean; + number?: number; + onSelectItem?: (item: S['SessionQueueItem']) => void; + } & FlexProps) => { + const onClick = useCallback(() => { + onSelectItem?.(item); + }, [item, onSelectItem]); + return ( + + + {number !== undefined && {`#${number}`}} + + ); + } +); +QueueItemStatusCard.displayName = 'QueueItemStatusCard'; + +const QueueItemStatusCardContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { + const socket = useStore($socket); + const [progressEvent, setProgressEvent] = useState(null); + const [progressImage, setProgressImage] = useState(null); + useEffect(() => { + if (!socket) { + return; + } + const onProgress = (data: S['InvocationProgressEvent']) => { + if (data.session_id !== item.session_id) { + return; + } + setProgressEvent(data); + if (data.image) { + setProgressImage(data.image); + } + }; + socket.on('invocation_progress', onProgress); + + return () => { + socket.off('invocation_progress', onProgress); + }; + }, [item.session_id, socket]); + + const imageDTO = useOutputImageDTO(item); + + if (item.status === 'pending') { + return ( + + Pending + + ); + } + if (item.status === 'canceled') { + return ( + + Canceled + + ); + } + if (item.status === 'failed') { + return ( + + Failed + + ); + } + if (item.status === 'in_progress' || !imageDTO) { + if (!progressImage) { + return ( + <> + + In Progress + + + + ); + } + return ( + <> + + + + ); + } + if (item.status === 'completed' && imageDTO && imageDTO !== IMAGE_DTO_ERROR) { + return ; + } + + if (item.status === 'completed') { + return ( + + Unable to get image + + ); + } + assert>(false); +}); +QueueItemStatusCardContent.displayName = 'QueueItemStatusCardContent'; + +const circleStyles: SystemStyleObject = { + circle: { + transitionProperty: 'none', + transitionDuration: '0s', + }, + position: 'absolute', + top: 2, + right: 2, +}; + +const ProgressCircle = ({ data }: { data?: S['InvocationProgressEvent'] | null }) => { + return ( + + + + ); +}; +ProgressCircle.displayName = 'ProgressCircle'; + +const QueueItemResultCard = memo(({ item }: { item: S['SessionQueueItem'] }) => { + const imageName = useMemo(() => { + const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => + isCanvasOutputNodeId(nodeId) + )?.[1][0]; + const output = nodeId ? item.session.results[nodeId] : undefined; + if (!output) { + return; + } + + for (const [_name, value] of objectEntries(output)) { + if (isImageField(value)) { + return value.image_name; + } + } + }, [item]); + + const { data: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); + + if (!imageDTO) { + return Unknown output type; + } + + return ; +}); +QueueItemResultCard.displayName = 'QueueItemResultCard'; + const SelectedImageOrProgressImage = memo(() => { const selectedImage = useAppSelector(selectSelectedImage); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts index dcce4be8b2..ce87203eeb 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts @@ -159,6 +159,8 @@ export const isMainModelWithoutUnet = (modelLoader: Invocation nodeId.split(':')[0] === CANVAS_OUTPUT_PREFIX; + export const isCanvasOutputEvent = (data: S['InvocationCompleteEvent']) => { - return data.invocation_source_id.split(':')[0] === CANVAS_OUTPUT_PREFIX; + return isCanvasOutputNodeId(data.invocation_source_id); }; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx index 2b80dae71f..d645dcbfe5 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx @@ -13,7 +13,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi'; import { useSelector } from 'react-redux'; -import type { SessionQueueItemDTO } from 'services/api/types'; +import type { S } from 'services/api/types'; import { COLUMN_WIDTHS } from './constants'; import QueueItemDetail from './QueueItemDetail'; @@ -23,7 +23,7 @@ const selectedStyles = { bg: 'base.700' }; type InnerItemProps = { index: number; - item: SessionQueueItemDTO; + item: S['SessionQueueItem']; context: ListContext; }; @@ -155,7 +155,7 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => { - + ); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx index 1068f51a14..a06e82ac5b 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx @@ -12,24 +12,20 @@ import type { ReactNode } from 'react'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi'; -import { useGetQueueItemQuery } from 'services/api/endpoints/queue'; -import type { SessionQueueItemDTO } from 'services/api/types'; +import type { S } from 'services/api/types'; type Props = { - queueItemDTO: SessionQueueItemDTO; + queueItem: S['SessionQueueItem']; }; -const QueueItemComponent = ({ queueItemDTO }: Props) => { - const { session_id, batch_id, item_id, origin, destination } = queueItemDTO; +const QueueItemComponent = ({ queueItem }: Props) => { + const { session_id, batch_id, item_id, origin, destination } = queueItem; const { t } = useTranslation(); const isRetryEnabled = useFeatureStatus('retryQueueItem'); const { cancelBatch, isLoading: isLoadingCancelBatch, isCanceled: isBatchCanceled } = useCancelBatch(batch_id); - const { cancelQueueItem, isLoading: isLoadingCancelQueueItem } = useCancelQueueItem(item_id); const { retryQueueItem, isLoading: isLoadingRetryQueueItem } = useRetryQueueItem(item_id); - const { data: queueItem } = useGetQueueItemQuery(item_id); - const originText = useOriginText(origin); const destinationText = useDestinationText(destination); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx index c1af4436f2..06137aa409 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx @@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next'; import type { Components, ItemContent } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue'; -import type { SessionQueueItemDTO } from 'services/api/types'; +import type { S } from 'services/api/types'; import QueueItemComponent from './QueueItemComponent'; import QueueListComponent from './QueueListComponent'; @@ -24,13 +24,13 @@ import type { ListContext } from './types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type TableVirtuosoScrollerRef = (ref: HTMLElement | Window | null) => any; -const computeItemKey = (index: number, item: SessionQueueItemDTO): number => item.item_id; +const computeItemKey = (index: number, item: S['SessionQueueItem']): number => item.item_id; -const components: Components = { +const components: Components = { List: QueueListComponent, }; -const itemContent: ItemContent = (index, item, context) => ( +const itemContent: ItemContent = (index, item, context) => ( ); @@ -114,7 +114,7 @@ const QueueList = () => { - + data={queueItems} endReached={handleLoadMore} scrollerRef={setScroller as TableVirtuosoScrollerRef} diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListComponent.tsx index f9ac99302e..932ef0d6d5 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListComponent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListComponent.tsx @@ -1,10 +1,10 @@ import { Flex, forwardRef, typedMemo } from '@invoke-ai/ui-library'; import type { Components } from 'react-virtuoso'; -import type { SessionQueueItemDTO } from 'services/api/types'; +import type { S } from 'services/api/types'; import type { ListContext } from './types'; -const QueueListComponent: Components['List'] = typedMemo( +const QueueListComponent: Components['List'] = typedMemo( forwardRef((props, ref) => { return ( diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/useDestinationText.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/useDestinationText.ts index e72d1d709c..ab8ea27de8 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/useDestinationText.ts +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/useDestinationText.ts @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; -import type { SessionQueueItemDTO } from 'services/api/types'; +import type { S } from 'services/api/types'; -export const useDestinationText = (destination: SessionQueueItemDTO['destination']) => { +export const useDestinationText = (destination: S['SessionQueueItem']['destination']) => { const { t } = useTranslation(); if (destination === 'canvas') { diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/useOriginText.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/useOriginText.ts index 9b3ac80272..deefbd08f4 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/useOriginText.ts +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/useOriginText.ts @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; -import type { SessionQueueItemDTO } from 'services/api/types'; +import type { S } from 'services/api/types'; -export const useOriginText = (origin: SessionQueueItemDTO['origin']) => { +export const useOriginText = (origin: S['SessionQueueItem']['origin']) => { const { t } = useTranslation(); if (origin === 'generation') { diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index 05906c2e56..fded3e8f09 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -5,9 +5,10 @@ import { $queueId } from 'app/store/nanostores/queueId'; import { listParamsReset } from 'features/queue/store/queueSlice'; import queryString from 'query-string'; import type { components, paths } from 'services/api/schema'; +import type { S } from 'services/api/types'; import type { ApiTagDescription } from '..'; -import { api, buildV1Url } from '..'; +import { api, buildV1Url, LIST_TAG } from '..'; /** * Builds an endpoint URL for the queue router @@ -35,7 +36,7 @@ export type SessionQueueItemStatus = NonNullable< NonNullable['status'] >; -export const queueItemsAdapter = createEntityAdapter({ +export const queueItemsAdapter = createEntityAdapter({ selectId: (queueItem) => String(queueItem.item_id), sortComparer: (a, b) => { // Sort by priority in descending order @@ -388,10 +389,10 @@ export const queueApi = api.injectEndpoints({ invalidatesTags: ['CurrentSessionQueueItem', 'NextSessionQueueItem', 'QueueCountsByDestination'], }), listQueueItems: build.query< - EntityState & { + EntityState & { has_more: boolean; }, - { cursor?: number; priority?: number } | undefined + { cursor?: number; priority?: number; destination?: string } | undefined >({ query: (queryArgs) => ({ url: getListQueueItemsUrl(queryArgs), @@ -400,20 +401,20 @@ export const queueApi = api.injectEndpoints({ serializeQueryArgs: () => { return buildQueueUrl('list'); }, - transformResponse: (response: components['schemas']['CursorPaginatedResults_SessionQueueItemDTO_']) => - queueItemsAdapter.addMany( + transformResponse: (response: components['schemas']['CursorPaginatedResults_SessionQueueItem_']) => + queueItemsAdapter.upsertMany( queueItemsAdapter.getInitialState({ has_more: response.has_more, }), response.items ), merge: (cache, response) => { - queueItemsAdapter.addMany(cache, queueItemsAdapterSelectors.selectAll(response)); + queueItemsAdapter.upsertMany(cache, queueItemsAdapterSelectors.selectAll(response)); cache.has_more = response.has_more; }, forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg, keepUnusedDataFor: 60 * 5, // 5 minutes - providesTags: ['FetchOnReconnect'], + providesTags: ['FetchOnReconnect', { type: 'SessionQueueItem', id: LIST_TAG }], }), getQueueCountsByDestination: build.query< paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['responses']['200']['content']['application/json'], diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 27c7c659e3..5d10535e66 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1153,7 +1153,7 @@ export type paths = { }; /** * List Queue Items - * @description Gets all queue items (without graphs) + * @description Gets cursor-paginated queue items */ get: operations["list_queue_items"]; put?: never; @@ -1164,6 +1164,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/queue/{queue_id}/all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List All Queue Items + * @description Gets all queue items + */ + get: operations["list_all_queue_items"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/queue/{queue_id}/processor/resume": { parameters: { query?: never; @@ -5715,8 +5735,8 @@ export type components = { */ type: "crop_latents"; }; - /** CursorPaginatedResults[SessionQueueItemDTO] */ - CursorPaginatedResults_SessionQueueItemDTO_: { + /** CursorPaginatedResults[SessionQueueItem] */ + CursorPaginatedResults_SessionQueueItem_: { /** * Limit * @description Limit of items to get @@ -5731,7 +5751,7 @@ export type components = { * Items * @description Items */ - items: components["schemas"]["SessionQueueItemDTO"][]; + items: components["schemas"]["SessionQueueItem"][]; }; /** * OpenCV Inpaint @@ -8742,47 +8762,47 @@ export type components = { * Id * @description The id of the execution state */ - id?: string; + id: string; /** @description The graph being executed */ graph: components["schemas"]["Graph"]; /** @description The expanded graph of activated and executed nodes */ - execution_graph?: components["schemas"]["Graph"]; + execution_graph: components["schemas"]["Graph"]; /** * Executed * @description The set of node ids that have been executed */ - executed?: string[]; + executed: string[]; /** * Executed History * @description The list of node ids that have been executed, in order of execution */ - executed_history?: string[]; + executed_history: string[]; /** * Results * @description The results of node executions */ - results?: { + results: { [key: string]: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"]; }; /** * Errors * @description Errors raised when executing nodes */ - errors?: { + errors: { [key: string]: string; }; /** * Prepared Source Mapping * @description The map of prepared nodes to original graph nodes */ - prepared_source_mapping?: { + prepared_source_mapping: { [key: string]: string; }; /** * Source Prepared Mapping * @description The map of original graph nodes to prepared nodes */ - source_prepared_mapping?: { + source_prepared_mapping: { [key: string]: string[]; }; }; @@ -19117,7 +19137,10 @@ export type components = { */ total: number; }; - /** SessionQueueItem */ + /** + * SessionQueueItem + * @description Session queue item without the full graph. Used for serialization. + */ SessionQueueItem: { /** * Item Id @@ -19218,16 +19241,6 @@ export type components = { * @description The ID of the published workflow associated with this queue item */ published_workflow_id?: string | null; - /** - * Api Input Fields - * @description The fields that were used as input to the API - */ - api_input_fields?: components["schemas"]["FieldIdentifier"][] | null; - /** - * Api Output Fields - * @description The nodes that were used as output from the API - */ - api_output_fields?: components["schemas"]["FieldIdentifier"][] | null; /** * Credits * @description The total credits used for this queue item @@ -19238,123 +19251,6 @@ export type components = { /** @description The workflow associated with this queue item */ workflow?: components["schemas"]["WorkflowWithoutID"] | null; }; - /** SessionQueueItemDTO */ - SessionQueueItemDTO: { - /** - * Item Id - * @description The identifier of the session queue item - */ - item_id: number; - /** - * Status - * @description The status of this queue item - * @default pending - * @enum {string} - */ - status: "pending" | "in_progress" | "completed" | "failed" | "canceled"; - /** - * Priority - * @description The priority of this queue item - * @default 0 - */ - priority: number; - /** - * Batch Id - * @description The ID of the batch associated with this queue item - */ - batch_id: string; - /** - * Origin - * @description The origin of this queue item. This data is used by the frontend to determine how to handle results. - */ - origin?: string | null; - /** - * Destination - * @description The origin of this queue item. This data is used by the frontend to determine how to handle results - */ - destination?: string | null; - /** - * Session Id - * @description The ID of the session associated with this queue item. The session doesn't exist in graph_executions until the queue item is executed. - */ - session_id: string; - /** - * Error Type - * @description The error type if this queue item errored - */ - error_type?: string | null; - /** - * Error Message - * @description The error message if this queue item errored - */ - error_message?: string | null; - /** - * Error Traceback - * @description The error traceback if this queue item errored - */ - error_traceback?: string | null; - /** - * Created At - * @description When this queue item was created - */ - created_at: string; - /** - * Updated At - * @description When this queue item was updated - */ - updated_at: string; - /** - * Started At - * @description When this queue item was started - */ - started_at?: string | null; - /** - * Completed At - * @description When this queue item was completed - */ - completed_at?: string | null; - /** - * Queue Id - * @description The id of the queue with which this item is associated - */ - queue_id: string; - /** - * Field Values - * @description The field values that were used for this queue item - */ - field_values?: components["schemas"]["NodeFieldValue"][] | null; - /** - * Retried From Item Id - * @description The item_id of the queue item that this item was retried from - */ - retried_from_item_id?: number | null; - /** - * Is Api Validation Run - * @description Whether this queue item is an API validation run. - * @default false - */ - is_api_validation_run?: boolean; - /** - * Published Workflow Id - * @description The ID of the published workflow associated with this queue item - */ - published_workflow_id?: string | null; - /** - * Api Input Fields - * @description The fields that were used as input to the API - */ - api_input_fields?: components["schemas"]["FieldIdentifier"][] | null; - /** - * Api Output Fields - * @description The nodes that were used as output from the API - */ - api_output_fields?: components["schemas"]["FieldIdentifier"][] | null; - /** - * Credits - * @description The total credits used for this queue item - */ - credits?: number | null; - }; /** SessionQueueStatus */ SessionQueueStatus: { /** @@ -24476,6 +24372,8 @@ export interface operations { cursor?: number | null; /** @description The pagination cursor priority */ priority?: number; + /** @description The destination of queue items to fetch */ + destination?: string | null; }; header?: never; path: { @@ -24492,7 +24390,44 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CursorPaginatedResults_SessionQueueItemDTO_"]; + "application/json": components["schemas"]["CursorPaginatedResults_SessionQueueItem_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_all_queue_items: { + parameters: { + query?: { + /** @description The status of items to fetch */ + status?: ("pending" | "in_progress" | "completed" | "failed" | "canceled") | null; + /** @description The destination of queue items to fetch */ + destination?: string | null; + }; + header?: never; + path: { + /** @description The queue id to perform this operation on */ + queue_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionQueueItem"][]; }; }; /** @description Validation Error */ diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 086ff3296f..5b7c234f1d 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -291,7 +291,6 @@ export type ModelInstallStatus = S['InstallStatus']; export type Graph = S['Graph']; export type NonNullableGraph = SetRequired; export type Batch = S['Batch']; -export type SessionQueueItemDTO = S['SessionQueueItemDTO']; export type WorkflowRecordOrderBy = S['WorkflowRecordOrderBy']; export type SQLiteDirection = S['SQLiteDirection']; export type WorkflowRecordListItemWithThumbnailDTO = S['WorkflowRecordListItemWithThumbnailDTO']; diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 1a873f5d43..357b86c7de 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -8,9 +8,7 @@ import { $bulkDownloadId } from 'app/store/nanostores/bulkDownloadId'; import { $queueId } from 'app/store/nanostores/queueId'; import type { AppStore } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { - stagingAreaGenerationStarted, -} from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { stagingAreaGenerationStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { $isInPublishFlow, $outputNodeId, @@ -25,7 +23,7 @@ import { forEach, isNil, round } from 'lodash-es'; import type { ApiTagDescription } from 'services/api'; import { api, LIST_TAG } from 'services/api'; import { modelsApi } from 'services/api/endpoints/models'; -import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue'; +import { queueApi } from 'services/api/endpoints/queue'; import { workflowsApi } from 'services/api/endpoints/workflows'; import { buildOnInvocationComplete } from 'services/events/onInvocationComplete'; import { buildOnModelInstallError } from 'services/events/onModelInstallError'; @@ -383,24 +381,24 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis log.debug({ data }, `Queue item ${item_id} status updated: ${status}`); - // Update this specific queue item in the list of queue items (this is the queue item DTO, without the session) - dispatch( - queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { - queueItemsAdapter.updateOne(draft, { - id: String(item_id), - changes: { - status, - started_at, - updated_at: updated_at ?? undefined, - completed_at: completed_at ?? undefined, - error_type, - error_message, - error_traceback, - credits, - }, - }); - }) - ); + // // Update this specific queue item in the list of queue items (this is the queue item DTO, without the session) + // dispatch( + // queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { + // queueItemsAdapter.updateOne(draft, { + // id: String(item_id), + // changes: { + // status, + // started_at, + // updated_at: updated_at ?? undefined, + // completed_at: completed_at ?? undefined, + // error_type, + // error_message, + // error_traceback, + // credits, + // }, + // }); + // }) + // ); // Optimistic update of the queue status. We prefer to do an optimistic update over tag invalidation due to the // frequency of `queue_item_status_changed` events. @@ -426,6 +424,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis 'NextSessionQueueItem', 'InvocationCacheStatus', { type: 'SessionQueueItem', id: item_id }, + { type: 'SessionQueueItem', id: LIST_TAG }, ]; if (destination) { tagsToInvalidate.push({ type: 'QueueCountsByDestination', id: destination }); From c9042e52d4981366627e7d297544eaf332f55ac3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:49:00 +1000 Subject: [PATCH 029/210] feat: canvas flow rework (wip) --- invokeai/app/api/routers/session_queue.py | 2 +- .../components/CanvasMainPanelContent.tsx | 15 +- .../queue/components/QueueList/QueueList.tsx | 4 +- .../web/src/services/api/endpoints/queue.ts | 272 +++++------------- .../frontend/web/src/services/api/index.ts | 1 + .../frontend/web/src/services/api/schema.ts | 2 +- .../src/services/events/setEventListeners.tsx | 58 +--- 7 files changed, 94 insertions(+), 260 deletions(-) diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index d21610fed3..7d6b8ac93c 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -91,7 +91,7 @@ async def list_queue_items( @session_queue_router.get( - "/{queue_id}/all", + "/{queue_id}/list_all", operation_id="list_all_queue_items", responses={ 200: {"model": list[SessionQueueItem]}, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 8f853f5d09..ba3f3c2a7f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -66,7 +66,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; import { getImageDTOSafe, useGetImageDTOQuery } from 'services/api/endpoints/images'; -import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue'; +import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; import type { ImageDTO, S } from 'services/api/types'; import type { ProgressAndResult } from 'services/events/stores'; import { $progressImages, $socket, useMapSelector } from 'services/events/stores'; @@ -351,17 +351,12 @@ const StagingArea = memo(() => { const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); const scrollableRef = useRef(null); - const { data } = useListQueueItemsQuery({ destination: 'canvas' }); - const items = useMemo(() => { - if (!data) { - return EMPTY_ARRAY; - } - return queueItemsAdapterSelectors.selectAll(data); - }, [data]); + const { data } = useListAllQueueItemsQuery({ destination: 'canvas' }); + const items = useMemo(() => data?.filter(({ status }) => status !== 'canceled') ?? EMPTY_ARRAY, [data]); const selectedItem = useMemo( () => - data && selectedItemId !== null ? queueItemsAdapterSelectors.selectById(data, String(selectedItemId)) : null, - [data, selectedItemId] + items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null, + [items, selectedItemId] ); useEffect(() => { diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx index 06137aa409..e812324335 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx @@ -13,7 +13,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { Components, ItemContent } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; -import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue'; +import { useListQueueItemsQuery } from 'services/api/endpoints/queue'; import type { S } from 'services/api/types'; import QueueItemComponent from './QueueItemComponent'; @@ -70,7 +70,7 @@ const QueueList = () => { if (!listQueueItemsData) { return []; } - return queueItemsAdapterSelectors.selectAll(listQueueItemsData); + return listQueueItemsData.items; }, [listQueueItemsData]); const handleLoadMore = useCallback(() => { diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index fded3e8f09..38f11b9928 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -1,14 +1,9 @@ -import type { EntityState, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; -import { createEntityAdapter } from '@reduxjs/toolkit'; -import { getSelectorsOptions } from 'app/store/createMemoizedSelector'; import { $queueId } from 'app/store/nanostores/queueId'; -import { listParamsReset } from 'features/queue/store/queueSlice'; import queryString from 'query-string'; import type { components, paths } from 'services/api/schema'; -import type { S } from 'services/api/types'; import type { ApiTagDescription } from '..'; -import { api, buildV1Url, LIST_TAG } from '..'; +import { api, buildV1Url, LIST_ALL_TAG, LIST_TAG } from '..'; /** * Builds an endpoint URL for the queue router @@ -36,30 +31,6 @@ export type SessionQueueItemStatus = NonNullable< NonNullable['status'] >; -export const queueItemsAdapter = createEntityAdapter({ - selectId: (queueItem) => String(queueItem.item_id), - sortComparer: (a, b) => { - // Sort by priority in descending order - if (a.priority > b.priority) { - return -1; - } - if (a.priority < b.priority) { - return 1; - } - - // If priority is the same, sort by id in ascending order - if (a.item_id < b.item_id) { - return -1; - } - if (a.item_id > b.item_id) { - return 1; - } - - return 0; - }, -}); -export const queueItemsAdapterSelectors = queueItemsAdapter.getSelectors(undefined, getSelectorsOptions); - export const queueApi = api.injectEndpoints({ endpoints: (build) => ({ enqueueBatch: build.mutation< @@ -71,58 +42,14 @@ export const queueApi = api.injectEndpoints({ body: arg, method: 'POST', }), - invalidatesTags: ['CurrentSessionQueueItem', 'NextSessionQueueItem', 'QueueCountsByDestination'], - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - const { data } = await queryFulfilled; - resetListQueryData(dispatch); - /** - * When a batch is enqueued, we need to update the queue status. While it might be templting to invalidate the - * `SessionQueueStatus` tag here, this can introduce a race condition when the queue item executes quickly: - * - * - Enqueue via this query - * - On success, we invalidate `SessionQueueStatus` tag - network request sent to server - * - The server gets the queue status request and responds, but this takes some time... in the meantime: - * - The new queue item starts executing, and we receive a socket queue item status changed event - * - We optimistically update the queue status in the queue item status changed socket handler - * - At this point, the queue status is correct - * - Finally, we get the queue status from the tag invalidation request - but it's reporting the queue status - * from _before_ the last queue event - * - The queue status is now incorrect! - * - * Ok, what if we just never did optimistic updates and invalidated the tag in the queue event handlers instead? - * It's much simpler that way, but it causes a lot of network requests - 3 per queue item, as it moves from - * pending -> in_progress -> completed/failed/canceled. - * - * We can do a bit of extra work here, incrementing the pending and total counts in the queue status, and do - * similar optimistic updates in the socket handler. Because this optimistic update runs immediately after the - * enqueue network request, it should always occur _before_ the next queue event, so no race condition: - * - * - Enqueue batch via this query - * - On success, optimistically update - this happens immediately on the HTTP OK - before the next queue event - * - At this point, the queue status is correct - * - A queue item status changes and we receive a socket event w/ updated status - * - Update status optimistically in socket handler - * - Queue status is still correct - * - * This problem occurs most commonly with canvas filters like Canny edge detection, which are single-node - * graphs that execute very quickly. Image generation graphs take long enough to not trigger this race - * condition - even when all nodes are cached on the server. - */ - dispatch( - queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) => { - if (!draft) { - return; - } - draft.queue.pending += data.enqueued; - draft.queue.total += data.enqueued; - }) - ); - } catch { - // no-op - } - }, + invalidatesTags: [ + 'SessionQueueStatus', + 'CurrentSessionQueueItem', + 'NextSessionQueueItem', + 'QueueCountsByDestination', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ], }), resumeProcessor: build.mutation< paths['/api/v1/queue/{queue_id}/processor/resume']['put']['responses']['200']['content']['application/json'], @@ -152,16 +79,7 @@ export const queueApi = api.injectEndpoints({ url: buildQueueUrl('prune'), method: 'PUT', }), - invalidatesTags: ['SessionQueueStatus', 'BatchStatus'], - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, + invalidatesTags: ['SessionQueueStatus', 'BatchStatus', { type: 'SessionQueueItem', id: LIST_TAG }], }), clearQueue: build.mutation< paths['/api/v1/queue/{queue_id}/clear']['put']['responses']['200']['content']['application/json'], @@ -178,16 +96,9 @@ export const queueApi = api.injectEndpoints({ 'CurrentSessionQueueItem', 'NextSessionQueueItem', 'QueueCountsByDestination', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, ], - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, }), getCurrentQueueItem: build.query< paths['/api/v1/queue/{queue_id}/current']['get']['responses']['200']['content']['application/json'], @@ -271,25 +182,6 @@ export const queueApi = api.injectEndpoints({ url: buildQueueUrl(`i/${item_id}/cancel`), method: 'PUT', }), - onQueryStarted: async (item_id, { dispatch, queryFulfilled }) => { - try { - const { data } = await queryFulfilled; - dispatch( - queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { - queueItemsAdapter.updateOne(draft, { - id: String(item_id), - changes: { - status: data.status, - completed_at: data.completed_at, - updated_at: data.updated_at, - }, - }); - }) - ); - } catch { - // no-op - } - }, invalidatesTags: (result) => { if (!result) { return []; @@ -313,16 +205,19 @@ export const queueApi = api.injectEndpoints({ method: 'PUT', body, }), - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op + invalidatesTags: (result, error, { batch_ids }) => { + if (!result) { + return []; } + return [ + 'SessionQueueStatus', + 'BatchStatus', + 'QueueCountsByDestination', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ...batch_ids.map((id) => ({ type: 'BatchStatus', id }) satisfies ApiTagDescription), + ]; }, - invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination'], }), cancelByBatchDestination: build.mutation< paths['/api/v1/queue/{queue_id}/cancel_by_destination']['put']['responses']['200']['content']['application/json'], @@ -333,20 +228,17 @@ export const queueApi = api.injectEndpoints({ method: 'PUT', params, }), - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, invalidatesTags: (result, error, { destination }) => { if (!result) { return []; } - return ['SessionQueueStatus', 'BatchStatus', { type: 'QueueCountsByDestination', id: destination }]; + return [ + 'SessionQueueStatus', + 'BatchStatus', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + { type: 'QueueCountsByDestination', id: destination }, + ]; }, }), cancelAllExceptCurrent: build.mutation< @@ -357,16 +249,7 @@ export const queueApi = api.injectEndpoints({ url: buildQueueUrl('cancel_all_except_current'), method: 'PUT', }), - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, - invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination'], + invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'], }), retryItemsById: build.mutation< paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['responses']['200']['content']['application/json'], @@ -377,44 +260,64 @@ export const queueApi = api.injectEndpoints({ method: 'PUT', body, }), - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op + invalidatesTags: (result, error, item_ids) => { + if (!result) { + return []; } + return [ + 'CurrentSessionQueueItem', + 'NextSessionQueueItem', + 'QueueCountsByDestination', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ...item_ids.map((id) => ({ type: 'SessionQueueItem', id }) satisfies ApiTagDescription), + ]; }, - invalidatesTags: ['CurrentSessionQueueItem', 'NextSessionQueueItem', 'QueueCountsByDestination'], }), listQueueItems: build.query< - EntityState & { - has_more: boolean; - }, + components['schemas']['CursorPaginatedResults_SessionQueueItem_'], { cursor?: number; priority?: number; destination?: string } | undefined >({ query: (queryArgs) => ({ url: getListQueueItemsUrl(queryArgs), method: 'GET', }), - serializeQueryArgs: () => { - return buildQueueUrl('list'); - }, - transformResponse: (response: components['schemas']['CursorPaginatedResults_SessionQueueItem_']) => - queueItemsAdapter.upsertMany( - queueItemsAdapter.getInitialState({ - has_more: response.has_more, - }), - response.items - ), - merge: (cache, response) => { - queueItemsAdapter.upsertMany(cache, queueItemsAdapterSelectors.selectAll(response)); - cache.has_more = response.has_more; - }, - forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg, keepUnusedDataFor: 60 * 5, // 5 minutes - providesTags: ['FetchOnReconnect', { type: 'SessionQueueItem', id: LIST_TAG }], + providesTags: (result, _error, _args) => { + if (!result) { + return []; + } + return [ + 'FetchOnReconnect', + { type: 'SessionQueueItem', id: LIST_TAG }, + ...result.items.map(({ item_id }) => ({ type: 'SessionQueueItem', id: item_id }) satisfies ApiTagDescription), + ]; + }, + }), + listAllQueueItems: build.query< + paths['/api/v1/queue/{queue_id}/list_all']['get']['responses']['200']['content']['application/json'], + paths['/api/v1/queue/{queue_id}/list_all']['get']['parameters']['query'] + >({ + query: (queryArgs) => { + const q = queryArgs + ? queryString.stringify(queryArgs, { + arrayFormat: 'none', + }) + : undefined; + + return q ? buildQueueUrl(`list_all?${q}`) : buildQueueUrl('list_all'); + }, + providesTags: (result, _error, _args) => { + if (!result) { + return []; + } + const tags: ApiTagDescription[] = [ + 'FetchOnReconnect', + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ...result.map(({ item_id }) => ({ type: 'SessionQueueItem', id: item_id }) satisfies ApiTagDescription), + ]; + return tags; + }, }), getQueueCountsByDestination: build.query< paths['/api/v1/queue/{queue_id}/counts_by_destination']['get']['responses']['200']['content']['application/json'], @@ -440,6 +343,7 @@ export const { useGetQueueStatusQuery, useGetQueueItemQuery, useListQueueItemsQuery, + useListAllQueueItemsQuery, useCancelQueueItemMutation, useGetBatchStatusQuery, useGetCurrentQueueItemQuery, @@ -450,24 +354,6 @@ export const { export const selectQueueStatus = queueApi.endpoints.getQueueStatus.select(); export const selectCanvasQueueCounts = queueApi.endpoints.getQueueCountsByDestination.select({ destination: 'canvas' }); -const resetListQueryData = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatch: ThunkDispatch -) => { - dispatch( - queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { - // remove all items from the list - queueItemsAdapter.removeAll(draft); - // reset the has_more flag - draft.has_more = false; - }) - ); - // set the list cursor and priority to undefined - dispatch(listParamsReset()); - // we have to manually kick off another query to get the first page and re-initialize the list - dispatch(queueApi.endpoints.listQueueItems.initiate(undefined)); -}; - export const enqueueMutationFixedCacheKeyOptions = { fixedCacheKey: 'enqueueBatch', } as const; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 7bc59202a4..123245fdfe 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -56,6 +56,7 @@ const tagTypes = [ ] as const; export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>; export const LIST_TAG = 'LIST'; +export const LIST_ALL_TAG = 'LIST_ALL'; const dynamicBaseQuery: BaseQueryFn = (args, api, extraOptions) => { const baseUrl = $baseUrl.get(); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 5d10535e66..65f2398d53 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1164,7 +1164,7 @@ export type paths = { patch?: never; trace?: never; }; - "/api/v1/queue/{queue_id}/all": { + "/api/v1/queue/{queue_id}/list_all": { parameters: { query?: never; header?: never; diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 357b86c7de..512b2aa317 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -21,7 +21,7 @@ import { toast } from 'features/toast/toast'; import { t } from 'i18next'; import { forEach, isNil, round } from 'lodash-es'; import type { ApiTagDescription } from 'services/api'; -import { api, LIST_TAG } from 'services/api'; +import { api, LIST_ALL_TAG, LIST_TAG } from 'services/api'; import { modelsApi } from 'services/api/endpoints/models'; import { queueApi } from 'services/api/endpoints/queue'; import { workflowsApi } from 'services/api/endpoints/workflows'; @@ -363,68 +363,20 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis socket.on('queue_item_status_changed', (data) => { // we've got new status for the queue item, batch and queue - const { - item_id, - session_id, - status, - started_at, - updated_at, - completed_at, - batch_status, - queue_status, - error_type, - error_message, - error_traceback, - destination, - credits, - } = data; + const { item_id, session_id, status, batch_status, error_type, error_message, destination } = data; log.debug({ data }, `Queue item ${item_id} status updated: ${status}`); - // // Update this specific queue item in the list of queue items (this is the queue item DTO, without the session) - // dispatch( - // queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { - // queueItemsAdapter.updateOne(draft, { - // id: String(item_id), - // changes: { - // status, - // started_at, - // updated_at: updated_at ?? undefined, - // completed_at: completed_at ?? undefined, - // error_type, - // error_message, - // error_traceback, - // credits, - // }, - // }); - // }) - // ); - - // Optimistic update of the queue status. We prefer to do an optimistic update over tag invalidation due to the - // frequency of `queue_item_status_changed` events. - dispatch( - queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) => { - if (!draft) { - return; - } - /** - * Update the queue status - though the getQueueStatus query response contains the processor status (i.e. running - * or paused), that data is not provided in the event we are handling. So we can only update `draft.queue` here. - */ - Object.assign(draft.queue, queue_status); - }) - ); - - // Update the batch status - dispatch(queueApi.util.updateQueryData('getBatchStatus', { batch_id: batch_status.batch_id }, () => batch_status)); - // Invalidate caches for things we cannot easily update const tagsToInvalidate: ApiTagDescription[] = [ + 'SessionQueueStatus', 'CurrentSessionQueueItem', 'NextSessionQueueItem', 'InvocationCacheStatus', { type: 'SessionQueueItem', id: item_id }, { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + { type: 'BatchStatus', id: batch_status.batch_id }, ]; if (destination) { tagsToInvalidate.push({ type: 'QueueCountsByDestination', id: destination }); From e80f0b2b43bc8b0f78554c8c1fc43d08d5e7a6ed Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 3 Jun 2025 19:12:07 +1000 Subject: [PATCH 030/210] fix(ui): unstable selector results in lora drop down --- .../hooks/useRelatedGroupedModelCombobox.ts | 100 ++++++++++++------ .../src/common/hooks/useRelatedModelKeys.ts | 16 ++- 2 files changed, 82 insertions(+), 34 deletions(-) diff --git a/invokeai/frontend/web/src/common/hooks/useRelatedGroupedModelCombobox.ts b/invokeai/frontend/web/src/common/hooks/useRelatedGroupedModelCombobox.ts index af14f5460e..ba06451256 100644 --- a/invokeai/frontend/web/src/common/hooks/useRelatedGroupedModelCombobox.ts +++ b/invokeai/frontend/web/src/common/hooks/useRelatedGroupedModelCombobox.ts @@ -1,12 +1,18 @@ import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; import type { GroupBase } from 'chakra-react-select'; +import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice'; +import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import type { ModelIdentifierField } from 'features/nodes/types/common'; +import { uniq } from 'lodash-es'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships'; import type { AnyModelConfig } from 'services/api/types'; import { useGroupedModelCombobox } from './useGroupedModelCombobox'; -import { useRelatedModelKeys } from './useRelatedModelKeys'; -import { useSelectedModelKeys } from './useSelectedModelKeys'; type UseRelatedGroupedModelComboboxArg = { modelConfigs: T[]; @@ -29,6 +35,32 @@ type UseRelatedGroupedModelComboboxReturn = { noOptionsMessage: () => string; }; +const selectSelectedModelKeys = createMemoizedSelector(selectParamsSlice, selectLoRAsSlice, (params, loras) => { + const keys: string[] = []; + const main = params.model; + const vae = params.vae; + const refiner = params.refinerModel; + const controlnet = params.controlLora; + + if (main) { + keys.push(main.key); + } + if (vae) { + keys.push(vae.key); + } + if (refiner) { + keys.push(refiner.key); + } + if (controlnet) { + keys.push(controlnet.key); + } + for (const { model } of loras.loras) { + keys.push(model.key); + } + + return uniq(keys); +}); + export function useRelatedGroupedModelCombobox({ modelConfigs, selectedModel, @@ -39,9 +71,15 @@ export function useRelatedGroupedModelCombobox({ }: UseRelatedGroupedModelComboboxArg): UseRelatedGroupedModelComboboxReturn { const { t } = useTranslation(); - const selectedKeys = useSelectedModelKeys(); - - const relatedKeys = useRelatedModelKeys(selectedKeys); + const selectedKeys = useAppSelector(selectSelectedModelKeys); + const { relatedKeys } = useGetRelatedModelIdsBatchQuery(selectedKeys, { + selectFromResult: ({ data }) => { + if (!data) { + return { relatedKeys: EMPTY_ARRAY }; + } + return { relatedKeys: data }; + }, + }); // Base grouped options const base = useGroupedModelCombobox({ @@ -53,40 +91,42 @@ export function useRelatedGroupedModelCombobox({ groupByType, }); - // If no related models selected, just return base - if (relatedKeys.size === 0) { - return base; - } + const options = useMemo(() => { + if (relatedKeys.length === 0) { + return base.options; + } - const relatedOptions: ComboboxOption[] = []; - const updatedGroups: GroupBase[] = []; + const relatedOptions: ComboboxOption[] = []; + const updatedGroups: GroupBase[] = []; - for (const group of base.options) { - const remainingOptions: ComboboxOption[] = []; + for (const group of base.options) { + const remainingOptions: ComboboxOption[] = []; - for (const option of group.options) { - if (relatedKeys.has(option.value)) { - relatedOptions.push({ ...option, label: `* ${option.label}` }); - } else { - remainingOptions.push(option); + for (const option of group.options) { + if (relatedKeys.includes(option.value)) { + relatedOptions.push({ ...option, label: `* ${option.label}` }); + } else { + remainingOptions.push(option); + } + } + + if (remainingOptions.length > 0) { + updatedGroups.push({ + label: group.label, + options: remainingOptions, + }); } } - if (remainingOptions.length > 0) { - updatedGroups.push({ - label: group.label, - options: remainingOptions, - }); + if (relatedOptions.length > 0) { + return [{ label: t('modelManager.relatedModels'), options: relatedOptions }, ...updatedGroups]; + } else { + return updatedGroups; } - } - - const finalOptions: GroupBase[] = - relatedOptions.length > 0 - ? [{ label: t('modelManager.relatedModels'), options: relatedOptions }, ...updatedGroups] - : updatedGroups; + }, [base.options, relatedKeys, t]); return { ...base, - options: finalOptions, + options, }; } diff --git a/invokeai/frontend/web/src/common/hooks/useRelatedModelKeys.ts b/invokeai/frontend/web/src/common/hooks/useRelatedModelKeys.ts index fc0711b969..9349c9b63d 100644 --- a/invokeai/frontend/web/src/common/hooks/useRelatedModelKeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useRelatedModelKeys.ts @@ -1,14 +1,22 @@ +import { EMPTY_ARRAY } from 'app/store/constants'; import { useMemo } from 'react'; import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships'; +const options: Parameters[1] = { + selectFromResult: ({ data }) => { + if (!data) { + return { related: EMPTY_ARRAY }; + } + return data; + }, +}; + /** * Fetches related model keys for a given set of selected model keys. * Returns a Set for fast lookup. */ -export const useRelatedModelKeys = (selectedKeys: Set) => { - const { data: related = [] } = useGetRelatedModelIdsBatchQuery([...selectedKeys], { - skip: selectedKeys.size === 0, - }); +export const useRelatedModelKeys = (selectedKeys: string[]) => { + const { related } = useGetRelatedModelIdsBatchQuery(selectedKeys, options); return useMemo(() => new Set(related), [related]); }; From 0e9b71801aa668129679ad88d25410d6221491e5 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 3 Jun 2025 19:12:14 +1000 Subject: [PATCH 031/210] feat: canvas flow rework (wip) --- .../components/CanvasMainPanelContent.tsx | 617 ++++++------------ .../web/src/services/events/stores.ts | 62 +- 2 files changed, 271 insertions(+), 408 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index ba3f3c2a7f..3db7cf89af 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,5 +1,5 @@ /* eslint-disable i18next/no-literal-string */ -import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Button, @@ -43,21 +43,13 @@ import { StagingAreaToolbar } from 'features/controlLayers/components/StagingAre import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { loadImage } from 'features/controlLayers/konva/util'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; -import { - canvasSessionStarted, - selectCanvasSessionType, - selectSelectedImage, - selectStagedImageIndex, - stagingAreaImageSelected, - stagingAreaNextStagedImageSelected, - stagingAreaPrevStagedImageSelected, -} from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionStarted, selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { newCanvasFromImage } from 'features/imageActions/actions'; -import type { ProgressImage } from 'features/nodes/types/common'; import { isImageField } from 'features/nodes/types/common'; import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; import type { ChangeEvent } from 'react'; @@ -65,11 +57,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; -import { getImageDTOSafe, useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; import type { ImageDTO, S } from 'services/api/types'; -import type { ProgressAndResult } from 'services/events/stores'; -import { $progressImages, $socket, useMapSelector } from 'services/events/stores'; +import { $socket, setProgress, useProgressData } from 'services/events/stores'; import type { Equals, Param0 } from 'tsafe'; import { assert, objectEntries } from 'tsafe'; @@ -100,7 +91,7 @@ export const CanvasMainPanelContent = memo(() => { } if (sessionType === 'simple') { - return ; + return ; } if (sessionType === 'advanced') { @@ -296,48 +287,6 @@ const GenerateWithStartingImageAndInpaintMask = memo(() => { }); GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask'; -const SimpleActiveSession = memo(() => { - const { getState, dispatch } = useAppStore(); - const selectedImage = useAppSelector(selectSelectedImage); - - const startOver = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: null })); - $progressImages.set({}); - }, [dispatch]); - - const goAdvanced = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); - }, [dispatch]); - - const selectNext = useCallback(() => { - dispatch(stagingAreaNextStagedImageSelected()); - }, [dispatch]); - - useHotkeys(['right'], selectNext, { preventDefault: true }, [selectNext]); - - const selectPrev = useCallback(() => { - dispatch(stagingAreaPrevStagedImageSelected()); - }, [dispatch]); - - useHotkeys(['left'], selectPrev, { preventDefault: true }, [selectPrev]); - - return ( - - - - Generations - - - - - - - ); -}); -SimpleActiveSession.displayName = 'SimpleActiveSession'; - const scrollIndicatorSx = { opacity: 0, '&[data-visible="true"]': { @@ -346,6 +295,7 @@ const scrollIndicatorSx = { } satisfies SystemStyleObject; const StagingArea = memo(() => { + const dispatch = useAppDispatch(); const [selectedItemId, setSelectedItemId] = useState(null); const [autoSwitch, setAutoSwitch] = useState(true); const [canScrollLeft, setCanScrollLeft] = useState(false); @@ -358,21 +308,15 @@ const StagingArea = memo(() => { items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null, [items, selectedItemId] ); + const selectedItemIndex = useMemo( + () => + items.length > 0 && selectedItemId !== null ? items.findIndex(({ item_id }) => item_id === selectedItemId) : null, + [items, selectedItemId] + ); - useEffect(() => { - if (items.length === 0) { - setSelectedItemId(null); - return; - } - if (selectedItem === null && items.length > 0) { - setSelectedItemId(items[0]?.item_id ?? null); - return; - } - if (selectedItemId === null || items.find((item) => item.item_id === selectedItemId) === undefined) { - return; - } - document.getElementById(`queue-item-status-card-${selectedItemId}`)?.scrollIntoView(); - }, [items, selectedItem, selectedItemId]); + const startOver = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: null })); + }, [dispatch]); useEffect(() => { const el = scrollableRef.current; @@ -393,13 +337,31 @@ const StagingArea = memo(() => { }; }, []); - const onSelectItem = useCallback((item: S['SessionQueueItem']) => { - setSelectedItemId(item.item_id); - if (item.status !== 'in_progress') { - setAutoSwitch(false); + const onSelectItemId = useCallback((item_id: number | null) => { + setSelectedItemId(item_id); + if (item_id !== null) { + document.getElementById(getCardId(item_id))?.scrollIntoView(); } }, []); + const onChangeAutoSwitch = useCallback((autoSwitch: boolean) => { + setAutoSwitch(autoSwitch); + }, []); + + useEffect(() => { + if (items.length === 0) { + onSelectItemId(null); + return; + } + if (selectedItem === null && items.length > 0) { + onSelectItemId(items[0]?.item_id ?? null); + return; + } + if (selectedItemId === null || items.find((item) => item.item_id === selectedItemId) === undefined) { + return; + } + }, [items, onSelectItemId, selectedItem, selectedItemId]); + const onNext = useCallback(() => { if (selectedItemId === null) { return; @@ -410,8 +372,8 @@ const StagingArea = memo(() => { if (!nextItem) { return; } - setSelectedItemId(nextItem.item_id); - }, [items, selectedItemId]); + onSelectItemId(nextItem.item_id); + }, [items, onSelectItemId, selectedItemId]); const onPrev = useCallback(() => { if (selectedItemId === null) { return; @@ -422,8 +384,8 @@ const StagingArea = memo(() => { if (!prevItem) { return; } - setSelectedItemId(prevItem.item_id); - }, [items, selectedItemId]); + onSelectItemId(prevItem.item_id); + }, [items, onSelectItemId, selectedItemId]); useHotkeys('left', onPrev); useHotkeys('right', onNext); @@ -443,7 +405,7 @@ const StagingArea = memo(() => { return; } if (data.status === 'in_progress') { - setSelectedItemId(data.item_id); + onSelectItemId(data.item_id); } }; @@ -452,35 +414,70 @@ const StagingArea = memo(() => { return () => { socket.off('queue_item_status_changed', onQueueItemStatusChanged); }; - }, [autoSwitch, socket]); + }, [autoSwitch, onSelectItemId, socket]); - const onChangeAutoSwitch = useCallback((e: ChangeEvent) => { + const _onChangeAutoSwitch = useCallback((e: ChangeEvent) => { setAutoSwitch(e.target.checked); }, []); + useEffect(() => { + if (!socket) { + return; + } + const onProgress = (data: S['InvocationProgressEvent']) => { + if (data.destination !== 'canvas') { + return; + } + setProgress(data); + }; + socket.on('invocation_progress', onProgress); + + return () => { + socket.off('invocation_progress', onProgress); + }; + }, [socket]); + return ( - - - {selectedItem && } - {!selectedItem && No queued generations} + + + + Generations + + + - - Auto-switch - - - - + + + {selectedItem && selectedItemIndex !== null && ( + + )} + {!selectedItem && No queued generations} + + + Auto-switch + + + + + {items.map((item, i) => ( - ))} @@ -514,103 +511,110 @@ const StagingArea = memo(() => { }); StagingArea.displayName = 'StagingArea'; -const IMAGE_DTO_ERROR = Symbol('IMAGE_DTO_ERROR'); - -const useOutputImageDTO = (item: S['SessionQueueItem']) => { - const [imageDTO, setImageDTO] = useState(null); - const syncImageDTO = useCallback(async (item: S['SessionQueueItem']) => { - const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => - isCanvasOutputNodeId(nodeId) - )?.[1][0]; - const output = nodeId ? item.session.results[nodeId] : undefined; - - if (!output) { - return setImageDTO(null); - } - - for (const [_name, value] of objectEntries(output)) { - if (isImageField(value)) { - const imageDTO = await getImageDTOSafe(value.image_name); - if (imageDTO) { - setImageDTO(imageDTO); - $progressImages.setKey(item.session_id, undefined); - return; - } - } - } - - setImageDTO(IMAGE_DTO_ERROR); - }, []); - useEffect(() => { - syncImageDTO(item); - }, [item, syncImageDTO]); - - return imageDTO; +const queueItemStatusCardMiniSx = { + cursor: 'pointer', + pos: 'relative', + borderWidth: 1, + borderRadius: 'base', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + aspectRatio: '1/1', + maxH: 'full', + maxW: 'full', + '&[data-selected="true"]': { + borderColor: 'invokeBlue.300', + }, + '&[data-size="mini"]': { + flexShrink: 0, + }, }; -const QueueItemStatusCard = memo( - ({ - item, - isSelected, - number, - onSelectItem, - ...rest - }: { - item: S['SessionQueueItem']; - isSelected: boolean; - number?: number; - onSelectItem?: (item: S['SessionQueueItem']) => void; - } & FlexProps) => { +const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`; + +type QueueItemStatusCardMiniProps = { + item: S['SessionQueueItem']; + isSelected: boolean; + number: number; + onSelectItemId: (item_id: number) => void; + onChangeAutoSwitch: (autoSwitch: boolean) => void; + size: 'mini' | 'full'; +}; + +const QueueItemCard = memo( + ({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch, size }: QueueItemStatusCardMiniProps) => { + const [isImageLoaded, setIsImageLoaded] = useState(false); + + const outputImageName = useMemo(() => { + const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => + isCanvasOutputNodeId(nodeId) + )?.[1][0]; + const output = nodeId ? item.session.results[nodeId] : undefined; + + if (!output) { + return null; + } + + for (const [_name, value] of objectEntries(output)) { + if (isImageField(value)) { + return value.image_name; + } + } + + return null; + }, [item.session.results, item.session.source_prepared_mapping]); + + const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); + + useEffect(() => { + if (imageDTO) { + loadImage(imageDTO.thumbnail_url, true).then(() => { + setIsImageLoaded(true); + }); + } + }, [imageDTO, item.session_id]); + const onClick = useCallback(() => { - onSelectItem?.(item); - }, [item, onSelectItem]); + onSelectItemId(item.item_id); + }, [item.item_id, onSelectItemId]); + + const onDoubleClick = useCallback(() => { + onChangeAutoSwitch(item.status === 'in_progress'); + }, [item.status, onChangeAutoSwitch]); + + if (imageDTO && isImageLoaded) { + return ( + + + {`#${number}`} + {size === 'full' && ( + + + + )} + + ); + } + return ( - - {number !== undefined && {`#${number}`}} + + {`#${number}`} ); } ); -QueueItemStatusCard.displayName = 'QueueItemStatusCard'; +QueueItemCard.displayName = 'QueueItemStatusCard'; -const QueueItemStatusCardContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { - const socket = useStore($socket); - const [progressEvent, setProgressEvent] = useState(null); - const [progressImage, setProgressImage] = useState(null); - useEffect(() => { - if (!socket) { - return; - } - const onProgress = (data: S['InvocationProgressEvent']) => { - if (data.session_id !== item.session_id) { - return; - } - setProgressEvent(data); - if (data.image) { - setProgressImage(data.image); - } - }; - socket.on('invocation_progress', onProgress); - - return () => { - socket.off('invocation_progress', onProgress); - }; - }, [item.session_id, socket]); - - const imageDTO = useOutputImageDTO(item); +const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { + const { progressEvent, progressImage } = useProgressData(item.session_id); if (item.status === 'pending') { return ( @@ -633,17 +637,8 @@ const QueueItemStatusCardContent = memo(({ item }: { item: S['SessionQueueItem'] ); } - if (item.status === 'in_progress' || !imageDTO) { - if (!progressImage) { - return ( - <> - - In Progress - - - - ); - } + + if (progressImage) { return ( <> @@ -651,8 +646,16 @@ const QueueItemStatusCardContent = memo(({ item }: { item: S['SessionQueueItem'] ); } - if (item.status === 'completed' && imageDTO && imageDTO !== IMAGE_DTO_ERROR) { - return ; + + if (item.status === 'in_progress') { + return ( + <> + + In Progress + + + + ); } if (item.status === 'completed') { @@ -664,7 +667,7 @@ const QueueItemStatusCardContent = memo(({ item }: { item: S['SessionQueueItem'] } assert>(false); }); -QueueItemStatusCardContent.displayName = 'QueueItemStatusCardContent'; +InProgressContent.displayName = 'InProgressContent'; const circleStyles: SystemStyleObject = { circle: { @@ -676,7 +679,7 @@ const circleStyles: SystemStyleObject = { right: 2, }; -const ProgressCircle = ({ data }: { data?: S['InvocationProgressEvent'] | null }) => { +const ProgressCircle = memo(({ data }: { data?: S['InvocationProgressEvent'] | null }) => { return ( ); -}; +}); ProgressCircle.displayName = 'ProgressCircle'; -const QueueItemResultCard = memo(({ item }: { item: S['SessionQueueItem'] }) => { - const imageName = useMemo(() => { - const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => - isCanvasOutputNodeId(nodeId) - )?.[1][0]; - const output = nodeId ? item.session.results[nodeId] : undefined; - if (!output) { - return; - } - - for (const [_name, value] of objectEntries(output)) { - if (isImageField(value)) { - return value.image_name; - } - } - }, [item]); - - const { data: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); - - if (!imageDTO) { - return Unknown output type; - } - - return ; -}); -QueueItemResultCard.displayName = 'QueueItemResultCard'; - -const SelectedImageOrProgressImage = memo(() => { - const selectedImage = useAppSelector(selectSelectedImage); - - if (selectedImage) { - return ; - } - - return ( - - No images - - ); -}); -SelectedImageOrProgressImage.displayName = 'SelectedImageOrProgressImage'; - -const SelectedImage = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { +const ImageActions = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { const { getState, dispatch } = useAppStore(); const vary = useCallback(() => { @@ -767,168 +728,20 @@ const SelectedImage = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { }); }, [dispatch, getState, imageDTO]); return ( - - - - - - - - - - + + + + + ); }); -SelectedImage.displayName = 'SelectedImage'; - -const FullSizeImage = memo(({ sessionId }: { sessionId: string }) => { - const _progressImage = useMapSelector(sessionId, $progressImages); - - if (!_progressImage) { - return ( - - Pending - - ); - } - - if (_progressImage.resultImage) { - return ; - } - - if (_progressImage.progressImage) { - return ( - - - - ); - } - - return ( - - No progress yet - - ); -}); -FullSizeImage.displayName = 'FullSizeImage'; - -const SessionImages = memo(() => { - const progressImages = useStore($progressImages); - return ( - - - {Object.values(progressImages).map((data, index) => { - if (data.type === 'staged') { - return ; - } else { - return ; - } - })} - - - ); -}); -SessionImages.displayName = 'SessionImages'; - -const ProgressImagePreview = ({ index, data }: { index: number; data: ProgressAndResult }) => { - const dispatch = useAppDispatch(); - const selectedImageIndex = useAppSelector(selectStagedImageIndex); - const onClick = useCallback(() => { - dispatch(stagingAreaImageSelected({ index })); - }, [dispatch, index]); - - useEffect(() => { - if (selectedImageIndex === index) { - // this doesn't work when the DndImage is in a popover... why - document.getElementById(getStagingImageId(data.sessionId))?.scrollIntoView(); - } - }, [data.sessionId, index, selectedImageIndex]); - - if (data.resultImage) { - return ( - - ); - } - - if (data.progressImage) { - return ( - - ); - } - - return ; -}; - -const getStagingImageId = (session_id: string) => `staging-image-${session_id}`; - -const sx = { - objectFit: 'contain', - maxW: 'full', - maxH: 'full', - w: 'min-content', - borderRadius: 'base', - cursor: 'grab', - '&[data-is-dragging=true]': { - opacity: 0.3, - }, - '&[data-is-selected="false"]': { - opacity: 0.5, - }, -} satisfies SystemStyleObject; -const SessionImage = memo(({ index, data }: { index: number; data: ProgressAndResult }) => { - const dispatch = useAppDispatch(); - const selectedImageIndex = useAppSelector(selectStagedImageIndex); - const onClick = useCallback(() => { - dispatch(stagingAreaImageSelected({ index })); - }, [dispatch, index]); - useEffect(() => { - if (selectedImageIndex === index) { - // this doesn't work when the DndImage is in a popover... why - document.getElementById(getStagingImageId(data.sessionId))?.scrollIntoView(); - } - }, [data.sessionId, index, selectedImageIndex]); - return ( - - ); -}); -SessionImage.displayName = 'SessionImage'; +ImageActions.displayName = 'ImageActions'; const CanvasActiveSession = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index e233e4fea1..f380019a5b 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -1,7 +1,6 @@ import type { EphemeralProgressImage } from 'features/controlLayers/store/types'; import type { ProgressImage } from 'features/nodes/types/common'; import { round } from 'lodash-es'; -import type { MapStore } from 'nanostores'; import { atom, computed, map } from 'nanostores'; import { useEffect, useState } from 'react'; import type { ImageDTO, S } from 'services/api/types'; @@ -21,20 +20,71 @@ export type ProgressAndResult = { }; export const $progressImages = map({} as Record); -export const useMapSelector = (id: string, map: MapStore>): T | undefined => { - const [value, setValue] = useState(); +type ProgressData = { + sessionId: string; + progressEvent: S['InvocationProgressEvent'] | null; + progressImage: ProgressImage | null; +}; + +export const $progressData = atom>({}); + +export const useProgressData = (sessionId: string): ProgressData => { + const [value, setValue] = useState({ sessionId, progressEvent: null, progressImage: null }); useEffect(() => { - const unsub = map.subscribe((data) => { - setValue(data[id]); + const unsub = $progressData.subscribe((data) => { + const progressData = data[sessionId]; + if (!progressData) { + return; + } + setValue(progressData); }); return () => { unsub(); }; - }, [id, map]); + }, [sessionId]); return value; }; +export const setProgress = (data: S['InvocationProgressEvent']) => { + const progressData = $progressData.get(); + const current = progressData[data.session_id]; + if (current) { + const next = { ...current }; + next.progressEvent = data; + if (data.image) { + next.progressImage = data.image; + } + $progressData.set({ + ...progressData, + [data.session_id]: next, + }); + } else { + $progressData.set({ + ...progressData, + [data.session_id]: { + sessionId: data.session_id, + progressEvent: data, + progressImage: data.image ?? null, + }, + }); + } +}; + +export const clearProgressImage = (sessionId: string) => { + const progressData = $progressData.get(); + const current = progressData[sessionId]; + if (!current) { + return; + } + const next = { ...current }; + next.progressImage = null; + $progressData.set({ + ...progressData, + [sessionId]: next, + }); +}; + export const $lastCanvasProgressEvent = atom(null); export const $lastCanvasProgressImage = atom(null); export const $lastWorkflowsProgressEvent = atom(null); From ad736bc19073b505162820ae310a0c2f30a7924e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 3 Jun 2025 20:54:19 +1000 Subject: [PATCH 032/210] feat: canvas flow rework (wip) --- .../listeners/enqueueRequestedLinear.ts | 14 +- .../components/CanvasMainPanelContent.tsx | 210 ++++++++++++++---- .../NewSessionConfirmationAlertDialog.tsx | 2 +- .../store/canvasStagingAreaSlice.ts | 30 ++- .../util/graph/buildLinearBatchConfig.ts | 2 +- .../services/events/onInvocationComplete.tsx | 42 ++-- .../src/services/events/setEventListeners.tsx | 7 +- .../web/src/services/events/stores.ts | 55 ++++- 8 files changed, 266 insertions(+), 96 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts index c6b38c517a..79413b94a0 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -5,7 +5,7 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware' import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; import { withResult, withResultAsync } from 'common/util/result'; import { parseify } from 'common/util/serialize'; -import { canvasSessionStarted, selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionStarted, selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { $canvasManager } from 'features/controlLayers/store/ephemeral'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph'; @@ -32,6 +32,11 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) actionCreator: enqueueRequestedCanvas, effect: async (action, { getState, dispatch }) => { log.debug('Enqueue requested'); + + if (!selectCanvasSession(getState())) { + dispatch(canvasSessionStarted({ sessionType: 'simple' })); + } + const state = getState(); const { prepend } = action.payload; @@ -91,7 +96,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) const { g, seedFieldIdentifier, positivePromptFieldIdentifier } = buildGraphResult.value; - // const destination = state.canvasSettings.sendToCanvas ? 'canvas' : 'gallery'; + const destination = state.canvasSession.session?.id ?? 'canvas'; const prepareBatchResult = withResult(() => prepareLinearUIBatch({ @@ -101,7 +106,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) seedFieldIdentifier, positivePromptFieldIdentifier, origin: 'canvas', - destination: 'canvas', + destination, }) ); @@ -116,9 +121,6 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) try { await req.unwrap(); - if (!selectCanvasSessionType(state)) { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); - } log.debug(parseify({ batchConfig: prepareBatchResult.value }), 'Enqueued batch'); } catch (error) { log.error({ error: serializeError(error as Error) }, 'Failed to enqueue batch'); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 3db7cf89af..0bf7d226ce 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -45,22 +45,25 @@ import { Transform } from 'features/controlLayers/components/Transform/Transform import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { loadImage } from 'features/controlLayers/konva/util'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; -import { canvasSessionStarted, selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionStarted, selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { newCanvasFromImage } from 'features/imageActions/actions'; import { isImageField } from 'features/nodes/types/common'; import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; +import { round } from 'lodash-es'; +import { atom, type WritableAtom } from 'nanostores'; import type { ChangeEvent } from 'react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; import type { ImageDTO, S } from 'services/api/types'; -import { $socket, setProgress, useProgressData } from 'services/events/stores'; +import type { ProgressData } from 'services/events/stores'; +import { $socket, clearProgressEvent, setProgress, useHasProgressImage, useProgressData } from 'services/events/stores'; import type { Equals, Param0 } from 'tsafe'; import { assert, objectEntries } from 'tsafe'; @@ -84,25 +87,45 @@ const MenuContent = memo(() => { MenuContent.displayName = 'MenuContent'; export const CanvasMainPanelContent = memo(() => { - const sessionType = useAppSelector(selectCanvasSessionType); + const session = useAppSelector(selectCanvasSession); - if (sessionType === null) { + if (session === null) { return ; } - if (sessionType === 'simple') { - return ; + if (session.type === 'simple') { + return ; } - if (sessionType === 'advanced') { + if (session.type === 'advanced') { return ; } - assert>(false, 'Unexpected sessionType'); + assert>(false, 'Unexpected session'); }); - CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; +const StagingAreaWrapper = memo(({ id }: { id: string }) => { + const ctx = useMemo( + () => + ({ + session: { + type: 'simple', + id, + }, + $progressData: atom>({}), + }) as const, + [id] + ); + + return ( + + + + ); +}); +StagingAreaWrapper.displayName = 'StagingAreaWrapper'; + const generateWithStartingImageDndTargetData = newCanvasFromImageDndTarget.getData({ type: 'raster_layer', withResize: true, @@ -116,6 +139,8 @@ const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getDat withResize: true, }); +const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))'; + const NoActiveSession = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -294,14 +319,36 @@ const scrollIndicatorSx = { }, } satisfies SystemStyleObject; +type StagingContextValue = { + session: + | { + type: 'simple'; + id: string; + } + | { + type: 'advanced'; + id: string; + }; + $progressData: WritableAtom>; +}; + +const StagingContext = createContext(null); + +const useStagingContext = () => { + const ctx = useContext(StagingContext); + assert(ctx !== null, 'use in stg prov'); + return ctx; +}; + const StagingArea = memo(() => { + const ctx = useStagingContext(); const dispatch = useAppDispatch(); const [selectedItemId, setSelectedItemId] = useState(null); const [autoSwitch, setAutoSwitch] = useState(true); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); const scrollableRef = useRef(null); - const { data } = useListAllQueueItemsQuery({ destination: 'canvas' }); + const { data } = useListAllQueueItemsQuery({ destination: ctx.session.id }); const items = useMemo(() => data?.filter(({ status }) => status !== 'canceled') ?? EMPTY_ARRAY, [data]); const selectedItem = useMemo( () => @@ -315,7 +362,7 @@ const StagingArea = memo(() => { ); const startOver = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: null })); + dispatch(canvasSessionStarted({ sessionType: 'simple' })); }, [dispatch]); useEffect(() => { @@ -353,13 +400,10 @@ const StagingArea = memo(() => { onSelectItemId(null); return; } - if (selectedItem === null && items.length > 0) { + if (selectedItemId === null && items.length > 0) { onSelectItemId(items[0]?.item_id ?? null); return; } - if (selectedItemId === null || items.find((item) => item.item_id === selectedItemId) === undefined) { - return; - } }, [items, onSelectItemId, selectedItem, selectedItemId]); const onNext = useCallback(() => { @@ -387,26 +431,42 @@ const StagingArea = memo(() => { onSelectItemId(prevItem.item_id); }, [items, onSelectItemId, selectedItemId]); - useHotkeys('left', onPrev); - useHotkeys('right', onNext); + const onFirst = useCallback(() => { + const first = items.at(0); + if (!first) { + return; + } + onSelectItemId(first.item_id); + }, [items, onSelectItemId]); + const onLast = useCallback(() => { + const last = items.at(-1); + if (!last) { + return; + } + onSelectItemId(last.item_id); + }, [items, onSelectItemId]); + + useHotkeys('left', onPrev, { preventDefault: true }); + useHotkeys('right', onNext, { preventDefault: true }); + useHotkeys('meta+left', onFirst, { preventDefault: true }); + useHotkeys('meta+right', onLast, { preventDefault: true }); const socket = useStore($socket); useEffect(() => { - if (!autoSwitch) { - return; - } - if (!socket) { return; } const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { - if (data.destination !== 'canvas') { + if (data.destination !== ctx.session.id) { return; } - if (data.status === 'in_progress') { + if (data.status === 'in_progress' && autoSwitch) { onSelectItemId(data.item_id); } + if (data.status === 'completed' || data.status === 'canceled' || data.status === 'failed') { + clearProgressEvent(ctx.$progressData, data.session_id); + } }; socket.on('queue_item_status_changed', onQueueItemStatusChanged); @@ -414,7 +474,7 @@ const StagingArea = memo(() => { return () => { socket.off('queue_item_status_changed', onQueueItemStatusChanged); }; - }, [autoSwitch, onSelectItemId, socket]); + }, [autoSwitch, ctx.$progressData, ctx.session.id, onSelectItemId, socket]); const _onChangeAutoSwitch = useCallback((e: ChangeEvent) => { setAutoSwitch(e.target.checked); @@ -425,17 +485,17 @@ const StagingArea = memo(() => { return; } const onProgress = (data: S['InvocationProgressEvent']) => { - if (data.destination !== 'canvas') { + if (data.destination !== ctx.session.id) { return; } - setProgress(data); + setProgress(ctx.$progressData, data); }; socket.on('invocation_progress', onProgress); return () => { socket.off('invocation_progress', onProgress); }; - }, [socket]); + }, [ctx.$progressData, ctx.session.id, socket]); return ( @@ -467,8 +527,8 @@ const StagingArea = memo(() => { - - + + {items.map((item, i) => ( { }); StagingArea.displayName = 'StagingArea'; -const queueItemStatusCardMiniSx = { +const queueItemCardSx = { cursor: 'pointer', pos: 'relative', borderWidth: 1, @@ -528,6 +588,9 @@ const queueItemStatusCardMiniSx = { '&[data-size="mini"]': { flexShrink: 0, }, + '&[data-size="full"]&[data-has-progress-image="false"]': { + w: 1024, + }, }; const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`; @@ -543,7 +606,9 @@ type QueueItemStatusCardMiniProps = { const QueueItemCard = memo( ({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch, size }: QueueItemStatusCardMiniProps) => { + const ctx = useStagingContext(); const [isImageLoaded, setIsImageLoaded] = useState(false); + const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); const outputImageName = useMemo(() => { const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => @@ -566,13 +631,23 @@ const QueueItemCard = memo( const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); - useEffect(() => { - if (imageDTO) { - loadImage(imageDTO.thumbnail_url, true).then(() => { - setIsImageLoaded(true); - }); + const syncIsReady = useCallback(async () => { + if (!imageDTO) { + setIsImageLoaded(false); + return; } - }, [imageDTO, item.session_id]); + try { + const _ = await loadImage(size === 'mini' ? imageDTO.thumbnail_url : imageDTO.image_url, true); + setIsImageLoaded(true); + return; + } catch { + setIsImageLoaded(false); + } + }, [imageDTO, size]); + + useEffect(() => { + syncIsReady(); + }, [syncIsReady]); const onClick = useCallback(() => { onSelectItemId(item.item_id); @@ -584,9 +659,16 @@ const QueueItemCard = memo( if (imageDTO && isImageLoaded) { return ( - - - {`#${number}`} + + + {`#${number}`} {size === 'full' && ( @@ -599,40 +681,72 @@ const QueueItemCard = memo( return ( - {`#${number}`} + {`#${number}`} + {size === 'full' && } ); } ); QueueItemCard.displayName = 'QueueItemStatusCard'; +const getMessage = (data: S['InvocationProgressEvent']) => { + let message = data.message; + if (data.percentage) { + message += ` (${round(data.percentage * 100)}%)`; + } + return message; +}; + +const ProgressMessage = memo(({ session_id }: { session_id: string }) => { + const { $progressData } = useStagingContext(); + const { progressEvent } = useProgressData($progressData, session_id); + if (!progressEvent) { + return null; + } + return ( + + {getMessage(progressEvent)} + + ); +}); +ProgressMessage.displayName = 'ProgressMessage'; + const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { - const { progressEvent, progressImage } = useProgressData(item.session_id); + const { $progressData } = useStagingContext(); + const { progressEvent, progressImage } = useProgressData($progressData, item.session_id); if (item.status === 'pending') { return ( - + Pending ); } if (item.status === 'canceled') { return ( - + Canceled ); } if (item.status === 'failed') { return ( - + Failed ); @@ -650,7 +764,7 @@ const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { if (item.status === 'in_progress') { return ( <> - + In Progress @@ -660,7 +774,7 @@ const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { if (item.status === 'completed') { return ( - + Unable to get image ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx index ef25dba0dc..fd65ae8095 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx @@ -20,7 +20,7 @@ export const useNewGallerySession = () => { const newSessionDialog = useNewGallerySessionDialog(); const newGallerySessionImmediate = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: null })); + dispatch(canvasSessionStarted({ sessionType: 'simple' })); dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index 2703286e72..bf035be31c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -1,17 +1,20 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; import type { StagingAreaImage, StagingAreaProgressImage } from 'features/controlLayers/store/types'; import { selectCanvasQueueCounts } from 'services/api/endpoints/queue'; type CanvasStagingAreaState = { + session: { type: 'simple'; id: string } | { type: 'advanced'; id: string } | null; sessionType: 'simple' | 'advanced' | null; images: (StagingAreaImage | StagingAreaProgressImage)[]; selectedImageIndex: number; }; const INITIAL_STATE: CanvasStagingAreaState = { + session: null, sessionType: null, images: [], selectedImageIndex: 0, @@ -23,6 +26,10 @@ export const canvasSessionSlice = createSlice({ name: 'canvasSession', initialState: getInitialState(), reducers: { + sessionChanged: (state, action: PayloadAction<{ session: CanvasStagingAreaState['session'] }>) => { + const { session } = action.payload; + state.session = session; + }, stagingAreaImageStaged: (state, action: PayloadAction<{ stagingAreaImage: StagingAreaImage }>) => { const { stagingAreaImage } = action.payload; let didReplace = false; @@ -67,11 +74,19 @@ export const canvasSessionSlice = createSlice({ state.images = []; state.selectedImageIndex = 0; }, - canvasSessionStarted: (_, action: PayloadAction<{ sessionType: CanvasStagingAreaState['sessionType'] }>) => { - const { sessionType } = action.payload; - const state = getInitialState(); - state.sessionType = sessionType; - return state; + canvasSessionStarted: { + reducer: (state, action: PayloadAction<{ session: CanvasStagingAreaState['session'] }>) => { + const { session } = action.payload; + state.session = session; + }, + prepare: (payload: { sessionType: 'simple' | 'advanced' }) => ({ + payload: { + session: { + type: payload.sessionType, + id: getPrefixedId(`canvas:${payload.sessionType}`), + }, + }, + }), }, }, extraReducers(builder) { @@ -80,6 +95,7 @@ export const canvasSessionSlice = createSlice({ }); export const { + sessionChanged, stagingAreaImageStaged, stagingAreaGenerationStarted, stagingAreaGenerationFinished, @@ -140,3 +156,7 @@ export const selectCanvasSessionType = createSelector( selectCanvasStagingAreaSlice, (canvasSession) => canvasSession.sessionType ); +export const selectCanvasSession = createSelector( + selectCanvasStagingAreaSlice, + (canvasSession) => canvasSession.session +); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts index 461dde930a..0daf113873 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts @@ -34,7 +34,7 @@ export const prepareLinearUIBatch = (arg: { seedFieldIdentifier?: FieldIdentifier; positivePromptFieldIdentifier: FieldIdentifier; origin: 'canvas' | 'workflows' | 'upscaling'; - destination: 'canvas' | 'gallery'; + destination: string; }): EnqueueBatchArg => { const { state, g, prepend, seedFieldIdentifier, positivePromptFieldIdentifier, origin, destination } = arg; const { iterations, model, shouldRandomizeSeed, seed, shouldConcatPrompts } = state.params; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index bcd51ca191..e26a2d05d7 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -1,22 +1,18 @@ import { logger } from 'app/logging/logger'; import type { AppDispatch, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { stagingAreaImageStaged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils'; -import { flushSync } from 'react-dom'; import type { ApiTagDescription } from 'services/api'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; import { - $lastCanvasProgressImage, $lastProgressEvent, - $progressImages, } from 'services/events/stores'; import type { Param0 } from 'tsafe'; import { objectEntries } from 'tsafe'; @@ -180,29 +176,29 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A await addImagesToGallery(data); - // We expect only a single image in the canvas output - const imageDTO = (await getResultImageDTOs(data))[0]; + // // We expect only a single image in the canvas output + // const imageDTO = (await getResultImageDTOs(data))[0]; - if (!imageDTO) { - return; - } + // if (!imageDTO) { + // return; + // } - flushSync(() => { - dispatch( - stagingAreaImageStaged({ - stagingAreaImage: { type: 'staged', sessionId: data.session_id, imageDTO, offsetX: 0, offsetY: 0 }, - }) - ); - }); + // flushSync(() => { + // dispatch( + // stagingAreaImageStaged({ + // stagingAreaImage: { type: 'staged', sessionId: data.session_id, imageDTO, offsetX: 0, offsetY: 0 }, + // }) + // ); + // }); - const progressData = $progressImages.get()[data.session_id]; - if (progressData) { - $progressImages.setKey(data.session_id, { ...progressData, isFinished: true, resultImage: imageDTO }); - } else { - $progressImages.setKey(data.session_id, { sessionId: data.session_id, isFinished: true, resultImage: imageDTO }); - } + // const progressData = $progressImages.get()[data.session_id]; + // if (progressData) { + // $progressImages.setKey(data.session_id, { ...progressData, isFinished: true, resultImage: imageDTO }); + // } else { + // $progressImages.setKey(data.session_id, { sessionId: data.session_id, isFinished: true, resultImage: imageDTO }); + // } - $lastCanvasProgressImage.set(null); + // $lastCanvasProgressImage.set(null); }; const handleOriginOther = async (data: S['InvocationCompleteEvent']) => { diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 512b2aa317..72ec0feb95 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -8,7 +8,6 @@ import { $bulkDownloadId } from 'app/store/nanostores/bulkDownloadId'; import { $queueId } from 'app/store/nanostores/queueId'; import type { AppStore } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { stagingAreaGenerationStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { $isInPublishFlow, $outputNodeId, @@ -397,8 +396,8 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis $nodeExecutionStates.setKey(clone.nodeId, clone); }); if (data.origin === 'canvas') { - store.dispatch(stagingAreaGenerationStarted({ sessionId: session_id })); - $progressImages.setKey(session_id, { sessionId: session_id, isFinished: false }); + // store.dispatch(stagingAreaGenerationStarted({ sessionId: session_id })); + // $progressImages.setKey(session_id, { sessionId: session_id, isFinished: false }); } } else if (status === 'completed' || status === 'failed' || status === 'canceled') { if (status === 'failed' && error_type) { @@ -423,7 +422,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis } // If the queue item is completed, failed, or cancelled, we want to clear the last progress event $lastProgressEvent.set(null); - $progressImages.setKey(session_id, undefined); + // $progressImages.setKey(session_id, undefined); // When a validation run is completed, we want to clear the validation run batch ID & set the workflow as published const validationRunData = $validationRunData.get(); diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index f380019a5b..b6c8305bfe 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -1,6 +1,7 @@ import type { EphemeralProgressImage } from 'features/controlLayers/store/types'; import type { ProgressImage } from 'features/nodes/types/common'; import { round } from 'lodash-es'; +import type { WritableAtom } from 'nanostores'; import { atom, computed, map } from 'nanostores'; import { useEffect, useState } from 'react'; import type { ImageDTO, S } from 'services/api/types'; @@ -20,16 +21,19 @@ export type ProgressAndResult = { }; export const $progressImages = map({} as Record); -type ProgressData = { +export type ProgressData = { sessionId: string; progressEvent: S['InvocationProgressEvent'] | null; progressImage: ProgressImage | null; }; -export const $progressData = atom>({}); - -export const useProgressData = (sessionId: string): ProgressData => { - const [value, setValue] = useState({ sessionId, progressEvent: null, progressImage: null }); +export const useProgressData = ( + $progressData: WritableAtom>, + sessionId: string +): ProgressData => { + const [value, setValue] = useState(() => { + return $progressData.get()[sessionId] ?? { sessionId, progressEvent: null, progressImage: null }; + }); useEffect(() => { const unsub = $progressData.subscribe((data) => { const progressData = data[sessionId]; @@ -41,12 +45,33 @@ export const useProgressData = (sessionId: string): ProgressData => { return () => { unsub(); }; - }, [sessionId]); + }, [$progressData, sessionId]); return value; }; -export const setProgress = (data: S['InvocationProgressEvent']) => { +export const useHasProgressImage = ( + $progressData: WritableAtom>, + sessionId: string +): boolean => { + const [value, setValue] = useState(false); + useEffect(() => { + const unsub = $progressData.subscribe((data) => { + const progressData = data[sessionId]; + setValue(Boolean(progressData?.progressImage)); + }); + return () => { + unsub(); + }; + }, [$progressData, sessionId]); + + return value; +}; + +export const setProgress = ( + $progressData: WritableAtom>, + data: S['InvocationProgressEvent'] +) => { const progressData = $progressData.get(); const current = progressData[data.session_id]; if (current) { @@ -71,7 +96,21 @@ export const setProgress = (data: S['InvocationProgressEvent']) => { } }; -export const clearProgressImage = (sessionId: string) => { +export const clearProgressEvent = ($progressData: WritableAtom>, sessionId: string) => { + const progressData = $progressData.get(); + const current = progressData[sessionId]; + if (!current) { + return; + } + const next = { ...current }; + next.progressEvent = null; + $progressData.set({ + ...progressData, + [sessionId]: next, + }); +}; + +export const clearProgressImage = ($progressData: WritableAtom>, sessionId: string) => { const progressData = $progressData.get(); const current = progressData[sessionId]; if (!current) { From 5dbc2a74a2140e81103353c3dcab66f6e8a28cff Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 3 Jun 2025 21:52:38 +1000 Subject: [PATCH 033/210] feat: canvas flow rework (wip) --- .../components/CanvasMainPanelContent.tsx | 134 +++++++++--------- 1 file changed, 68 insertions(+), 66 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 0bf7d226ce..17e23ff26f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,5 +1,5 @@ /* eslint-disable i18next/no-literal-string */ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import type { ButtonGroupProps, SystemStyleObject, TextProps } from '@invoke-ai/ui-library'; import { Box, Button, @@ -26,6 +26,7 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; +import { IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; @@ -63,7 +64,7 @@ import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; import type { ImageDTO, S } from 'services/api/types'; import type { ProgressData } from 'services/events/stores'; -import { $socket, clearProgressEvent, setProgress, useHasProgressImage, useProgressData } from 'services/events/stores'; +import { $socket, clearProgressImage, setProgress, useHasProgressImage, useProgressData } from 'services/events/stores'; import type { Equals, Param0 } from 'tsafe'; import { assert, objectEntries } from 'tsafe'; @@ -464,9 +465,6 @@ const StagingArea = memo(() => { if (data.status === 'in_progress' && autoSwitch) { onSelectItemId(data.item_id); } - if (data.status === 'completed' || data.status === 'canceled' || data.status === 'failed') { - clearProgressEvent(ctx.$progressData, data.session_id); - } }; socket.on('queue_item_status_changed', onQueueItemStatusChanged); @@ -512,6 +510,7 @@ const StagingArea = memo(() => { {selectedItem && selectedItemIndex !== null && ( { {items.map((item, i) => ( `queue-item-status-card-${item_id}`; @@ -607,7 +606,6 @@ type QueueItemStatusCardMiniProps = { const QueueItemCard = memo( ({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch, size }: QueueItemStatusCardMiniProps) => { const ctx = useStagingContext(); - const [isImageLoaded, setIsImageLoaded] = useState(false); const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); const outputImageName = useMemo(() => { @@ -631,24 +629,6 @@ const QueueItemCard = memo( const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); - const syncIsReady = useCallback(async () => { - if (!imageDTO) { - setIsImageLoaded(false); - return; - } - try { - const _ = await loadImage(size === 'mini' ? imageDTO.thumbnail_url : imageDTO.image_url, true); - setIsImageLoaded(true); - return; - } catch { - setIsImageLoaded(false); - } - }, [imageDTO, size]); - - useEffect(() => { - syncIsReady(); - }, [syncIsReady]); - const onClick = useCallback(() => { onSelectItemId(item.item_id); }, [item.item_id, onSelectItemId]); @@ -657,22 +637,38 @@ const QueueItemCard = memo( onChangeAutoSwitch(item.status === 'in_progress'); }, [item.status, onChangeAutoSwitch]); - if (imageDTO && isImageLoaded) { + const syncIsReady = useCallback(async () => { + if (!imageDTO) { + return; + } + try { + await loadImage(imageDTO.image_url, true); + clearProgressImage(ctx.$progressData, item.session_id); + return; + } catch { + // noop + } + }, [ctx.$progressData, imageDTO, item.session_id]); + + useEffect(() => { + syncIsReady(); + }, [syncIsReady]); + + if (imageDTO && !hasProgressImage) { return ( - - {`#${number}`} + + {size === 'mini' && } {size === 'full' && ( - - - + <> + + + )} ); @@ -689,15 +685,13 @@ const QueueItemCard = memo( onDoubleClick={onDoubleClick} > - {`#${number}`} - {size === 'full' && } + {size === 'mini' && } + {size === 'full' && ( + <> + + + + )} ); } @@ -712,14 +706,19 @@ const getMessage = (data: S['InvocationProgressEvent']) => { return message; }; -const ProgressMessage = memo(({ session_id }: { session_id: string }) => { +const ItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => { + return {`#${number}`}; +}); +ItemNumber.displayName = 'ItemNumber'; + +const ProgressMessage = memo(({ session_id, ...rest }: { session_id: string } & TextProps) => { const { $progressData } = useStagingContext(); const { progressEvent } = useProgressData($progressData, session_id); if (!progressEvent) { return null; } return ( - + {getMessage(progressEvent)} ); @@ -755,7 +754,14 @@ const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { if (progressImage) { return ( <> - + ); @@ -773,11 +779,7 @@ const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { } if (item.status === 'completed') { - return ( - - Unable to get image - - ); + return ; } assert>(false); }); @@ -809,7 +811,7 @@ const ProgressCircle = memo(({ data }: { data?: S['InvocationProgressEvent'] | n }); ProgressCircle.displayName = 'ProgressCircle'; -const ImageActions = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { +const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => { const { getState, dispatch } = useAppStore(); const vary = useCallback(() => { @@ -842,7 +844,7 @@ const ImageActions = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { }); }, [dispatch, getState, imageDTO]); return ( - + From e16414b452680cfd26c80bac53ec57810806b5a7 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:44:37 +1000 Subject: [PATCH 034/210] tidy(ui): app layout components --- .../components/CanvasRightPanel.tsx | 4 +- .../components/CanvasRightPanelStacked.tsx | 4 +- .../Boards/BoardsList/BoardsList.tsx | 7 +- .../Boards/BoardsList/BoardsListWrapper.tsx | 7 +- .../Boards/BoardsList/BoardsSearch.tsx | 7 +- .../Boards/BoardsListSortControls.tsx | 7 +- .../Boards/BoardsSettingsPopover.tsx | 7 +- .../components/BoardsListPanelContent.tsx | 25 +++ .../features/gallery/components/Gallery.tsx | 9 +- .../components/GalleryPanelContent.tsx | 159 ------------------ .../GallerySettingsPopover.tsx | 7 +- .../gallery/components/GalleryTopBar.tsx | 67 ++++++++ .../components/GalleryUploadButton.tsx | 10 +- .../src/features/ui/components/AppContent.tsx | 60 +++---- .../ui/components/FloatingGalleryButton.tsx | 28 --- ...ttons.tsx => FloatingLeftPanelButtons.tsx} | 87 +++++----- .../components/FloatingRightPanelButtons.tsx | 28 +++ .../ui/components/LeftPanelContent.tsx | 25 +++ .../ui/components/MainPanelContent.tsx | 33 ++++ .../ui/components/RightPanelContent.tsx | 94 +++++++++++ 20 files changed, 379 insertions(+), 296 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/GalleryPanelContent.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx delete mode 100644 invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx rename invokeai/frontend/web/src/features/ui/components/{FloatingParametersPanelButtons.tsx => FloatingLeftPanelButtons.tsx} (71%) create mode 100644 invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx create mode 100644 invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx create mode 100644 invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx create mode 100644 invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index c1bca93a26..4e1b3d2c69 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -9,7 +9,7 @@ import { selectEntityCountActive } from 'features/controlLayers/store/selectors' import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import type { DndTargetState } from 'features/dnd/types'; -import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; +import RightPanelContent from 'features/gallery/components/GalleryTopBar'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; @@ -61,7 +61,7 @@ export const CanvasRightPanel = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx index 6ca1f26ed3..4806728f27 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx @@ -9,7 +9,7 @@ import { selectEntityCountActive } from 'features/controlLayers/store/selectors' import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import type { DndTargetState } from 'features/dnd/types'; -import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; +import RightPanelContent from 'features/gallery/components/GalleryTopBar'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; @@ -49,7 +49,7 @@ export const CanvasRightPanelStacked = memo(() => { return ( - + diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index fbe21e2218..3b48882b5c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -8,7 +8,7 @@ import { selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { selectAllowPrivateBoards } from 'features/system/store/configSelectors'; -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; @@ -21,7 +21,7 @@ type Props = { isPrivate: boolean; }; -export const BoardsList = ({ isPrivate }: Props) => { +export const BoardsList = memo(({ isPrivate }: Props) => { const { t } = useTranslation(); const selectedBoardId = useAppSelector(selectSelectedBoardId); const boardSearchText = useAppSelector(selectBoardSearchText); @@ -118,4 +118,5 @@ export const BoardsList = ({ isPrivate }: Props) => { ); -}; +}); +BoardsList.displayName = 'BoardsList'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx index ca944e8981..4b6c403020 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx @@ -17,7 +17,7 @@ const overlayScrollbarsStyles: CSSProperties = { width: '100%', }; -const BoardsListWrapper = () => { +export const BoardsListWrapper = memo(() => { const allowPrivateBoards = useAppSelector(selectAllowPrivateBoards); const [os, osRef] = useState(null); useEffect(() => { @@ -54,5 +54,6 @@ const BoardsListWrapper = () => { ); -}; -export default memo(BoardsListWrapper); +}); + +BoardsListWrapper.displayName = 'BoardsListWrapper'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx index f2ea242240..5c578a5539 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx @@ -7,7 +7,7 @@ import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -const BoardsSearch = () => { +export const BoardsSearch = memo(() => { const dispatch = useAppDispatch(); const boardSearchText = useAppSelector(selectBoardSearchText); const { t } = useTranslation(); @@ -62,6 +62,5 @@ const BoardsSearch = () => { )} ); -}; - -export default memo(BoardsSearch); +}); +BoardsSearch.displayName = 'BoardsSearch'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListSortControls.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListSortControls.tsx index 47514b0b60..e4ed79189b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListSortControls.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsListSortControls.tsx @@ -3,7 +3,7 @@ import { Combobox, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectBoardsListOrderBy, selectBoardsListOrderDir } from 'features/gallery/store/gallerySelectors'; import { boardsListOrderByChanged, boardsListOrderDirChanged } from 'features/gallery/store/gallerySlice'; -import { useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; @@ -15,7 +15,7 @@ const zDirection = z.enum(['ASC', 'DESC']); type Direction = z.infer; const isDirection = (v: unknown): v is Direction => zDirection.safeParse(v).success; -export const BoardsListSortControls = () => { +export const BoardsListSortControls = memo(() => { const { t } = useTranslation(); const orderBy = useAppSelector(selectBoardsListOrderBy); @@ -83,4 +83,5 @@ export const BoardsListSortControls = () => { ); -}; +}); +BoardsListSortControls.displayName = 'BoardsListSortControls'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx index 9fa890bd6b..ac338a66a1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsSettingsPopover.tsx @@ -17,7 +17,7 @@ import { PiGearSixFill } from 'react-icons/pi'; import { BoardsListSortControls } from './BoardsListSortControls'; -const BoardsSettingsPopover = () => { +export const BoardsSettingsPopover = memo(() => { const { t } = useTranslation(); return ( @@ -48,6 +48,5 @@ const BoardsSettingsPopover = () => { ); -}; - -export default memo(BoardsSettingsPopover); +}); +BoardsSettingsPopover.displayName = 'BoardsSettingsPopover'; diff --git a/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx b/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx new file mode 100644 index 0000000000..b456cbaaa7 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx @@ -0,0 +1,25 @@ +import type { UseDisclosureReturn } from '@invoke-ai/ui-library'; +import { Box, Collapse, Divider, Flex } from '@invoke-ai/ui-library'; +import { BoardsListWrapper } from 'features/gallery/components/Boards/BoardsList/BoardsListWrapper'; +import { BoardsSearch } from 'features/gallery/components/Boards/BoardsList/BoardsSearch'; +import type { CSSProperties } from 'react'; +import { memo } from 'react'; + +const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 }; + +export const BoardsListPanelContent = memo( + ({ boardSearchDisclosure }: { boardSearchDisclosure: UseDisclosureReturn }) => { + return ( + + + + + + + + + + ); + } +); +BoardsListPanelContent.displayName = 'BoardsListPanelContent'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 7836b99c68..785bc53af8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -18,12 +18,12 @@ import { useGallerySearchTerm } from 'features/gallery/components/ImageGrid/useG import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; import { galleryViewChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { CSSProperties } from 'react'; -import { useCallback } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiMagnifyingGlassBold } from 'react-icons/pi'; import { useBoardName } from 'services/api/hooks/useBoardName'; -import GallerySettingsPopover from './GallerySettingsPopover/GallerySettingsPopover'; +import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover'; import { GalleryUploadButton } from './GalleryUploadButton'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; import { GalleryPagination } from './ImageGrid/GalleryPagination'; @@ -46,7 +46,7 @@ const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '10 const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); -export const Gallery = () => { +export const Gallery = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const galleryView = useAppSelector(selectGalleryView); @@ -116,4 +116,5 @@ export const Gallery = () => { ); -}; +}); +Gallery.displayName = 'Gallery'; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryPanelContent.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryPanelContent.tsx deleted file mode 100644 index ef28a923ac..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryPanelContent.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { - Box, - Button, - Collapse, - Divider, - Flex, - IconButton, - type SystemStyleObject, - useDisclosure, -} from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; -import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; -import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { GalleryHeader } from 'features/gallery/components/GalleryHeader'; -import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; -import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; -import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle'; -import { usePanel, type UsePanelOptions } from 'features/ui/hooks/usePanel'; -import type { CSSProperties } from 'react'; -import { memo, useCallback, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi'; -import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; -import { Panel, PanelGroup } from 'react-resizable-panels'; - -import BoardsListWrapper from './Boards/BoardsList/BoardsListWrapper'; -import BoardsSearch from './Boards/BoardsList/BoardsSearch'; -import BoardsSettingsPopover from './Boards/BoardsSettingsPopover'; -import { Gallery } from './Gallery'; - -const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 }; - -const FOCUS_REGION_STYLES: SystemStyleObject = { - width: 'full', - height: 'full', - position: 'relative', - flexDirection: 'column', - display: 'flex', -}; - -const GalleryPanelContent = () => { - const { t } = useTranslation(); - const boardSearchText = useAppSelector(selectBoardSearchText); - const dispatch = useAppDispatch(); - const boardSearchDisclosure = useDisclosure({ defaultIsOpen: !!boardSearchText.length }); - const imperativePanelGroupRef = useRef(null); - const sessionType = useAppSelector(selectCanvasSessionType); - - const boardsListPanelOptions = useMemo( - () => ({ - id: 'boards-list-panel', - minSizePx: 128, - defaultSizePx: 256, - imperativePanelGroupRef, - panelGroupDirection: 'vertical', - }), - [] - ); - const boardsListPanel = usePanel(boardsListPanelOptions); - - const galleryPanelOptions = useMemo( - () => ({ - id: 'gallery-panel', - minSizePx: 128, - defaultSizePx: 256, - imperativePanelGroupRef, - panelGroupDirection: 'vertical', - }), - [] - ); - const galleryPanel = usePanel(galleryPanelOptions); - - const canvasLayersPanelOptions = useMemo( - () => ({ - id: 'canvas-layers-panel', - minSizePx: 128, - defaultSizePx: 256, - imperativePanelGroupRef, - panelGroupDirection: 'vertical', - }), - [] - ); - const canvasLayersPanel = usePanel(canvasLayersPanelOptions); - - const handleClickBoardSearch = useCallback(() => { - if (boardSearchText.length) { - dispatch(boardSearchTextChanged('')); - } - boardSearchDisclosure.onToggle(); - boardsListPanel.expand(); - }, [boardSearchText.length, boardSearchDisclosure, boardsListPanel, dispatch]); - - return ( - - - - - - - - - - - } - colorScheme={boardSearchDisclosure.isOpen ? 'invokeBlue' : 'base'} - /> - - - - - - - - - - - - - - - - - - - - {sessionType === 'advanced' && ( - <> - - - - - - - - )} - - - ); -}; - -export default memo(GalleryPanelContent); diff --git a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/GallerySettingsPopover.tsx b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/GallerySettingsPopover.tsx index a5d0861486..7ca1f23c58 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/GallerySettingsPopover.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover/GallerySettingsPopover.tsx @@ -8,7 +8,7 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiGearSixFill } from 'react-icons/pi'; -const GallerySettingsPopover = () => { +export const GallerySettingsPopover = memo(() => { const { t } = useTranslation(); return ( @@ -37,6 +37,5 @@ const GallerySettingsPopover = () => { ); -}; - -export default memo(GallerySettingsPopover); +}); +GallerySettingsPopover.displayName = 'GallerySettingsPopover'; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx new file mode 100644 index 0000000000..e84e733c58 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx @@ -0,0 +1,67 @@ +import type { UseDisclosureReturn } from '@invoke-ai/ui-library'; +import { Button, Flex, IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { BoardsSettingsPopover } from 'features/gallery/components/Boards/BoardsSettingsPopover'; +import { GalleryHeader } from 'features/gallery/components/GalleryHeader'; +import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; +import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; +import type { UsePanelReturn } from 'features/ui/hooks/usePanel'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi'; + +export const GalleryTopBar = memo( + ({ + boardsListPanel, + boardSearchDisclosure, + }: { + boardsListPanel: UsePanelReturn; + boardSearchDisclosure: UseDisclosureReturn; + }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const boardSearchText = useAppSelector(selectBoardSearchText); + + const onClickBoardSearch = useCallback(() => { + if (boardSearchText.length) { + dispatch(boardSearchTextChanged('')); + } + boardSearchDisclosure.onToggle(); + boardsListPanel.expand(); + }, [boardSearchText.length, boardSearchDisclosure, boardsListPanel, dispatch]); + + return ( + + + + + + + + + + } + colorScheme={boardSearchDisclosure.isOpen ? 'invokeBlue' : 'base'} + /> + + + ); + } +); +GalleryTopBar.displayName = 'GalleryTopBar'; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx index ca6cc78051..af3b980947 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx @@ -1,10 +1,13 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { t } from 'i18next'; +import { memo } from 'react'; import { PiUploadBold } from 'react-icons/pi'; -export const GalleryUploadButton = () => { - const uploadApi = useImageUploadButton({ allowMultiple: true }); +const UPLOAD_OPTIONS: Parameters[0] = { allowMultiple: true }; + +export const GalleryUploadButton = memo(() => { + const uploadApi = useImageUploadButton(UPLOAD_OPTIONS); return ( <> { ); -}; +}); +GalleryUploadButton.displayName = 'GalleryUploadButton'; diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index 6704ab4d00..83f220e4a6 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -1,16 +1,15 @@ import { Box, Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasMainPanelContent } from 'features/controlLayers/components/CanvasMainPanelContent'; -import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useDndMonitor } from 'features/dnd/useDndMonitor'; -import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; import WorkflowsTabLeftPanel from 'features/nodes/components/sidePanel/WorkflowsTabLeftPanel'; import QueueControls from 'features/queue/components/QueueControls'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton'; -import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons'; +import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; +import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; +import { RightPanelContent } from 'features/ui/components/RightPanelContent'; import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab'; import QueueTab from 'features/ui/components/tabs/QueueTab'; import { WorkflowsMainPanel } from 'features/ui/components/tabs/WorkflowsTabContent'; @@ -30,6 +29,8 @@ import type { CSSProperties } from 'react'; import { memo, useMemo, useRef } from 'react'; import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale'; import { VerticalResizeHandle } from './tabs/ResizeHandle'; @@ -128,26 +129,21 @@ export const AppContent = memo(() => { > {withLeftPanel && ( <> - - - - - - - + + )} - {withLeftPanel && } - {withRightPanel && } + {withLeftPanel && } + {withRightPanel && } {withRightPanel && ( <> - + @@ -156,35 +152,21 @@ export const AppContent = memo(() => { ); }); - AppContent.displayName = 'AppContent'; -const RightPanelContent = memo(() => { - const tab = useAppSelector(selectActiveTab); - const sessionType = useAppSelector(selectCanvasSessionType); - - if (tab === 'upscaling' || tab === 'workflows' || tab === 'canvas') { - return ; - } - - return null; -}); -RightPanelContent.displayName = 'RightPanelContent'; - const LeftPanelContent = memo(() => { const tab = useAppSelector(selectActiveTab); - if (tab === 'canvas') { - return ; - } - if (tab === 'upscaling') { - return ; - } - if (tab === 'workflows') { - return ; - } - - return null; + return ( + + + + {tab === 'canvas' && } + {tab === 'upscaling' && } + {tab === 'workflows' && } + + + ); }); LeftPanelContent.displayName = 'LeftPanelContent'; @@ -207,6 +189,6 @@ const MainPanelContent = memo(() => { return ; } - return null; + assert>(false); }); MainPanelContent.displayName = 'MainPanelContent'; diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx deleted file mode 100644 index 20c38b2d55..0000000000 --- a/invokeai/frontend/web/src/features/ui/components/FloatingGalleryButton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Flex, IconButton, Tooltip } from '@invoke-ai/ui-library'; -import type { UsePanelReturn } from 'features/ui/hooks/usePanel'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiImagesSquareBold } from 'react-icons/pi'; - -type Props = { - panelApi: UsePanelReturn; -}; - -const FloatingGalleryButton = (props: Props) => { - const { t } = useTranslation(); - - return ( - - - } - h={48} - /> - - - ); -}; - -export default memo(FloatingGalleryButton); diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx similarity index 71% rename from invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx rename to invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx index ddb77c6918..bbd10d868c 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingParametersPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx @@ -2,15 +2,14 @@ import { ButtonGroup, Flex, Icon, IconButton, spinAnimation, Tooltip, useShiftMo import { useAppSelector } from 'app/store/storeHooks'; import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { useClearQueueDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip'; import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; import { useInvoke } from 'features/queue/hooks/useInvoke'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCircleNotchBold, @@ -23,60 +22,48 @@ import { } from 'react-icons/pi'; import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; -type Props = { - togglePanel: () => void; -}; - -const FloatingSidePanelButtons = ({ togglePanel }: Props) => { - const { t } = useTranslation(); +export const FloatingLeftPanelButtons = memo((props: { onToggle: () => void }) => { const tab = useAppSelector(selectActiveTab); - const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll'); - const sessionType = useAppSelector(selectCanvasSessionType); + const session = useAppSelector(selectCanvasSession); return ( - {tab === 'canvas' && sessionType === 'advanced' && ( + {tab === 'canvas' && session?.type === 'advanced' && ( )} - - } - flexGrow={1} - /> - + - {/* Show the cancel all except current button instead of cancel and clear all when it is disabled */} - {isCancelAndClearAllEnabled && } - {!isCancelAndClearAllEnabled && } + ); -}; +}); -export default memo(FloatingSidePanelButtons); +FloatingLeftPanelButtons.displayName = 'FloatingLeftPanelButtons'; + +const ToggleLeftPanelButton = memo((props: { onToggle: () => void }) => { + const { t } = useTranslation(); + return ( + + } + flexGrow={1} + /> + + ); +}); +ToggleLeftPanelButton.displayName = 'ToggleLeftPanelButton'; const InvokeIconButton = memo(() => { const { t } = useTranslation(); const queue = useInvoke(); const shift = useShiftModifier(); - const { data: queueStatus } = useGetQueueStatusQuery(); - - const queueButtonIcon = useMemo(() => { - const isProcessing = (queueStatus?.queue.in_progress ?? 0) > 0; - if (!queue.isDisabled && isProcessing) { - return ; - } - if (shift) { - return ; - } - return ; - }, [queue.isDisabled, queueStatus?.queue.in_progress, shift]); return ( @@ -85,7 +72,7 @@ const InvokeIconButton = memo(() => { onClick={shift ? queue.enqueueFront : queue.enqueueBack} isLoading={queue.isLoading} isDisabled={queue.isDisabled} - icon={queueButtonIcon} + icon={} colorScheme="invokeYellow" flexGrow={1} /> @@ -94,6 +81,30 @@ const InvokeIconButton = memo(() => { }); InvokeIconButton.displayName = 'InvokeIconButton'; +const InvokeIconButtonIcon = memo(() => { + const shift = useShiftModifier(); + const queue = useInvoke(); + const { isProcessing } = useGetQueueStatusQuery(undefined, { + selectFromResult: ({ data }) => { + if (!data) { + return { isProcessing: false }; + } + return { isProcessing: data.queue.in_progress > 0 }; + }, + }); + + if (!queue.isDisabled && isProcessing) { + return ; + } + + if (shift) { + return ; + } + + return ; +}); +InvokeIconButtonIcon.displayName = 'InvokeIconButtonIcon'; + const CancelCurrentIconButton = memo(() => { const { t } = useTranslation(); const cancelCurrentQueueItem = useCancelCurrentQueueItem(); diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx new file mode 100644 index 0000000000..3c33bca265 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/FloatingRightPanelButtons.tsx @@ -0,0 +1,28 @@ +import { Flex, IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiImagesSquareBold } from 'react-icons/pi'; + +export const FloatingRightPanelButtons = memo((props: { onToggle: () => void }) => { + return ( + + + + ); +}); +FloatingRightPanelButtons.displayName = 'FloatingRightPanelButtons'; + +const ToggleRightPanelButton = memo((props: { onToggle: () => void }) => { + const { t } = useTranslation(); + return ( + + } + h={48} + /> + + ); +}); +ToggleRightPanelButton.displayName = 'ToggleRightPanelButton'; diff --git a/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx new file mode 100644 index 0000000000..873e803186 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx @@ -0,0 +1,25 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import WorkflowsTabLeftPanel from 'features/nodes/components/sidePanel/WorkflowsTabLeftPanel'; +import QueueControls from 'features/queue/components/QueueControls'; +import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; +import { memo } from 'react'; + +import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale'; + +const LeftPanelContent = memo(() => { + const tab = useAppSelector(selectActiveTab); + + return ( + + + + {tab === 'canvas' && } + {tab === 'upscaling' && } + {tab === 'workflows' && } + + + ); +}); +LeftPanelContent.displayName = 'LeftPanelContent'; diff --git a/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx new file mode 100644 index 0000000000..58ac5a4558 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx @@ -0,0 +1,33 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasMainPanelContent } from 'features/controlLayers/components/CanvasMainPanelContent'; +import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; +import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab'; +import QueueTab from 'features/ui/components/tabs/QueueTab'; +import { WorkflowsMainPanel } from 'features/ui/components/tabs/WorkflowsTabContent'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; +import { memo } from 'react'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; + +const MainPanelContent = memo(() => { + const tab = useAppSelector(selectActiveTab); + + if (tab === 'canvas') { + return ; + } + if (tab === 'upscaling') { + return ; + } + if (tab === 'workflows') { + return ; + } + if (tab === 'models') { + return ; + } + if (tab === 'queue') { + return ; + } + + assert>(false); +}); +MainPanelContent.displayName = 'MainPanelContent'; diff --git a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx new file mode 100644 index 0000000000..049e52f901 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx @@ -0,0 +1,94 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { useDisclosure } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; +import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; +import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { BoardsListPanelContent } from 'features/gallery/components/BoardsListPanelContent'; +import { Gallery } from 'features/gallery/components/Gallery'; +import { GalleryTopBar } from 'features/gallery/components/GalleryTopBar'; +import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; +import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle'; +import type { UsePanelOptions } from 'features/ui/hooks/usePanel'; +import { usePanel } from 'features/ui/hooks/usePanel'; +import { memo, useMemo, useRef } from 'react'; +import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; +import { Panel, PanelGroup } from 'react-resizable-panels'; + +const FOCUS_REGION_STYLES: SystemStyleObject = { + width: 'full', + height: 'full', + position: 'relative', + flexDirection: 'column', + display: 'flex', +}; + +export const RightPanelContent = memo(() => { + const boardSearchText = useAppSelector(selectBoardSearchText); + const boardSearchDisclosure = useDisclosure({ defaultIsOpen: !!boardSearchText.length }); + const imperativePanelGroupRef = useRef(null); + const session = useAppSelector(selectCanvasSession); + + const boardsListPanelOptions = useMemo( + () => ({ + id: 'boards-list-panel', + minSizePx: 128, + defaultSizePx: 256, + imperativePanelGroupRef, + panelGroupDirection: 'vertical', + }), + [] + ); + const boardsListPanel = usePanel(boardsListPanelOptions); + + const galleryPanelOptions = useMemo( + () => ({ + id: 'gallery-panel', + minSizePx: 128, + defaultSizePx: 256, + imperativePanelGroupRef, + panelGroupDirection: 'vertical', + }), + [] + ); + const galleryPanel = usePanel(galleryPanelOptions); + + const canvasLayersPanelOptions = useMemo( + () => ({ + id: 'canvas-layers-panel', + minSizePx: 128, + defaultSizePx: 256, + imperativePanelGroupRef, + panelGroupDirection: 'vertical', + }), + [] + ); + const canvasLayersPanel = usePanel(canvasLayersPanelOptions); + + return ( + + + + + + + + + + + {session?.type === 'advanced' && ( + <> + + + + + + + + )} + + + ); +}); +RightPanelContent.displayName = 'RightPanelContent'; From 0af20b03e58992f0e2c6315a37c2f82f3207d274 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 12:32:43 +1000 Subject: [PATCH 035/210] feat(api): remove status from list all queue items query --- invokeai/app/api/routers/session_queue.py | 2 -- invokeai/app/services/session_queue/session_queue_base.py | 1 - .../app/services/session_queue/session_queue_sqlite.py | 7 ------- 3 files changed, 10 deletions(-) diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 7d6b8ac93c..660d09728c 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -99,14 +99,12 @@ async def list_queue_items( ) async def list_all_queue_items( queue_id: str = Path(description="The queue id to perform this operation on"), - status: Optional[QUEUE_ITEM_STATUS] = Query(default=None, description="The status of items to fetch"), destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), ) -> list[SessionQueueItem]: """Gets all queue items""" return ApiDependencies.invoker.services.session_queue.list_all_queue_items( queue_id=queue_id, - status=status, destination=destination, ) diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 187fa2e815..a2aa844742 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -135,7 +135,6 @@ class SessionQueueBase(ABC): def list_all_queue_items( self, queue_id: str, - status: Optional[QUEUE_ITEM_STATUS] = None, destination: Optional[str] = None, ) -> list[SessionQueueItem]: """Gets all queue items that match the given parameters""" diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 81239338ec..4ba16fb85a 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -588,7 +588,6 @@ class SqliteSessionQueue(SessionQueueBase): def list_all_queue_items( self, queue_id: str, - status: Optional[QUEUE_ITEM_STATUS] = None, destination: Optional[str] = None, ) -> list[SessionQueueItem]: """Gets all queue items that match the given parameters""" @@ -600,12 +599,6 @@ class SqliteSessionQueue(SessionQueueBase): """ params: list[Union[str, int]] = [queue_id] - if status is not None: - query += """--sql - AND status = ? - """ - params.append(status) - if destination is not None: query += """---sql AND destination = ? From 84f70942e7188bcdd7b9c37cbc61e789c0272c97 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 12:32:54 +1000 Subject: [PATCH 036/210] chore(ui): typegen --- invokeai/frontend/web/src/services/api/schema.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 65f2398d53..d44246571e 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -24407,8 +24407,6 @@ export interface operations { list_all_queue_items: { parameters: { query?: { - /** @description The status of items to fetch */ - status?: ("pending" | "in_progress" | "completed" | "failed" | "canceled") | null; /** @description The destination of queue items to fetch */ destination?: string | null; }; From db4220fb205030e2347c206461d341e1d0db64fb Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 12:33:00 +1000 Subject: [PATCH 037/210] feat: canvas flow rework (wip) --- .../components/CanvasMainPanelContent.tsx | 473 ++++++++++-------- 1 file changed, 262 insertions(+), 211 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 17e23ff26f..6692ee1c9e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -313,13 +313,31 @@ const GenerateWithStartingImageAndInpaintMask = memo(() => { }); GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask'; -const scrollIndicatorSx = { +const scrollIndicatorBaseSx = { opacity: 0, + position: 'absolute', + w: 16, + h: 'full', + transitionProperty: 'opacity', + transitionDuration: '0.3s', + pointerEvents: 'none', '&[data-visible="true"]': { opacity: 1, }, } satisfies SystemStyleObject; +const scrollIndicatorLeftSx = { + ...scrollIndicatorBaseSx, + left: 0, + bg: 'linear-gradient(to right, var(--invoke-colors-base-900), transparent)', +} satisfies SystemStyleObject; + +const scrollIndicatorRightSx = { + ...scrollIndicatorBaseSx, + right: 0, + bg: 'linear-gradient(to left, var(--invoke-colors-base-900), transparent)', +} satisfies SystemStyleObject; + type StagingContextValue = { session: | { @@ -341,72 +359,11 @@ const useStagingContext = () => { return ctx; }; -const StagingArea = memo(() => { - const ctx = useStagingContext(); - const dispatch = useAppDispatch(); - const [selectedItemId, setSelectedItemId] = useState(null); - const [autoSwitch, setAutoSwitch] = useState(true); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - const scrollableRef = useRef(null); - const { data } = useListAllQueueItemsQuery({ destination: ctx.session.id }); - const items = useMemo(() => data?.filter(({ status }) => status !== 'canceled') ?? EMPTY_ARRAY, [data]); - const selectedItem = useMemo( - () => - items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null, - [items, selectedItemId] - ); - const selectedItemIndex = useMemo( - () => - items.length > 0 && selectedItemId !== null ? items.findIndex(({ item_id }) => item_id === selectedItemId) : null, - [items, selectedItemId] - ); - - const startOver = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); - }, [dispatch]); - - useEffect(() => { - const el = scrollableRef.current; - if (!el) { - return; - } - const onScroll = () => { - const { scrollLeft, scrollWidth, clientWidth } = el; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft + clientWidth < scrollWidth); - }; - el.addEventListener('scroll', onScroll); - const observer = new ResizeObserver(onScroll); - observer.observe(el); - return () => { - el.removeEventListener('scroll', onScroll); - observer.disconnect(); - }; - }, []); - - const onSelectItemId = useCallback((item_id: number | null) => { - setSelectedItemId(item_id); - if (item_id !== null) { - document.getElementById(getCardId(item_id))?.scrollIntoView(); - } - }, []); - - const onChangeAutoSwitch = useCallback((autoSwitch: boolean) => { - setAutoSwitch(autoSwitch); - }, []); - - useEffect(() => { - if (items.length === 0) { - onSelectItemId(null); - return; - } - if (selectedItemId === null && items.length > 0) { - onSelectItemId(items[0]?.item_id ?? null); - return; - } - }, [items, onSelectItemId, selectedItem, selectedItemId]); - +const useStagingAreaKeyboardNav = ( + items: S['SessionQueueItem'][], + selectedItemId: number | null, + onSelectItemId: (item_id: number) => void +) => { const onNext = useCallback(() => { if (selectedItemId === null) { return; @@ -451,6 +408,78 @@ const StagingArea = memo(() => { useHotkeys('right', onNext, { preventDefault: true }); useHotkeys('meta+left', onFirst, { preventDefault: true }); useHotkeys('meta+right', onLast, { preventDefault: true }); +}; + +const LIST_ALL_OPTIONS = { + selectFromResult: ({ data }) => { + if (!data) { + return { items: EMPTY_ARRAY }; + } + return { items: data.filter(({ status }) => status !== 'canceled') }; + }, +} satisfies Parameters[1]; + +const StagingArea = memo(() => { + const ctx = useStagingContext(); + const [selectedItemId, setSelectedItemId] = useState(null); + const [autoSwitch, setAutoSwitch] = useState(true); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const scrollableRef = useRef(null); + const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS); + const selectedItem = useMemo( + () => + items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null, + [items, selectedItemId] + ); + const selectedItemIndex = useMemo( + () => + items.length > 0 && selectedItemId !== null ? items.findIndex(({ item_id }) => item_id === selectedItemId) : null, + [items, selectedItemId] + ); + + useEffect(() => { + const el = scrollableRef.current; + if (!el) { + return; + } + const onScroll = () => { + const { scrollLeft, scrollWidth, clientWidth } = el; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft + clientWidth < scrollWidth); + }; + el.addEventListener('scroll', onScroll); + const observer = new ResizeObserver(onScroll); + observer.observe(el); + return () => { + el.removeEventListener('scroll', onScroll); + observer.disconnect(); + }; + }, []); + + const onSelectItemId = useCallback((item_id: number | null) => { + setSelectedItemId(item_id); + if (item_id !== null) { + document.getElementById(getCardId(item_id))?.scrollIntoView(); + } + }, []); + + useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId); + + const onChangeAutoSwitch = useCallback((autoSwitch: boolean) => { + setAutoSwitch(autoSwitch); + }, []); + + useEffect(() => { + if (items.length === 0) { + onSelectItemId(null); + return; + } + if (selectedItemId === null && items.length > 0) { + onSelectItemId(items[0]?.item_id ?? null); + return; + } + }, [items, onSelectItemId, selectedItem, selectedItemId]); const socket = useStore($socket); useEffect(() => { @@ -497,29 +526,17 @@ const StagingArea = memo(() => { return ( - - - Generations - - - - + {selectedItem && selectedItemIndex !== null && ( - )} - {!selectedItem && No queued generations} + {!selectedItem && No generation selected} Auto-switch @@ -529,48 +546,46 @@ const StagingArea = memo(() => { {items.map((item, i) => ( - ))} - - + + ); }); StagingArea.displayName = 'StagingArea'; -const queueItemCardSx = { +const StagingAreaHeader = memo(() => { + const dispatch = useAppDispatch(); + + const startOver = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: 'simple' })); + }, [dispatch]); + + return ( + + + Generations + + + + + ); +}); +StagingAreaHeader.displayName = 'StagingAreaHeader'; + +const miniQueueItemSx = { cursor: 'pointer', pos: 'relative', alignItems: 'center', @@ -581,122 +596,154 @@ const queueItemCardSx = { maxW: 'full', minW: 0, minH: 0, - '&[data-size="mini"]': { - borderWidth: 1, - borderRadius: 'base', - '&[data-selected="true"]': { - borderColor: 'invokeBlue.300', - }, - aspectRatio: '1/1', - flexShrink: 0, + borderWidth: 1, + borderRadius: 'base', + '&[data-selected="true"]': { + borderColor: 'invokeBlue.300', }, + aspectRatio: '1/1', + flexShrink: 0, }; const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`; -type QueueItemStatusCardMiniProps = { - item: S['SessionQueueItem']; - isSelected: boolean; - number: number; - onSelectItemId: (item_id: number) => void; - onChangeAutoSwitch: (autoSwitch: boolean) => void; - size: 'mini' | 'full'; -}; +const useOutputImageDTO = (item: S['SessionQueueItem']) => { + const ctx = useStagingContext(); -const QueueItemCard = memo( - ({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch, size }: QueueItemStatusCardMiniProps) => { - const ctx = useStagingContext(); - const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); - - const outputImageName = useMemo(() => { - const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => - isCanvasOutputNodeId(nodeId) - )?.[1][0]; - const output = nodeId ? item.session.results[nodeId] : undefined; - - if (!output) { - return null; - } - - for (const [_name, value] of objectEntries(output)) { - if (isImageField(value)) { - return value.image_name; - } - } + const outputImageName = useMemo(() => { + const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => + isCanvasOutputNodeId(nodeId) + )?.[1][0]; + const output = nodeId ? item.session.results[nodeId] : undefined; + if (!output) { return null; - }, [item.session.results, item.session.source_prepared_mapping]); + } - const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); - - const onClick = useCallback(() => { - onSelectItemId(item.item_id); - }, [item.item_id, onSelectItemId]); - - const onDoubleClick = useCallback(() => { - onChangeAutoSwitch(item.status === 'in_progress'); - }, [item.status, onChangeAutoSwitch]); - - const syncIsReady = useCallback(async () => { - if (!imageDTO) { - return; + for (const [_name, value] of objectEntries(output)) { + if (isImageField(value)) { + return value.image_name; } + } + + return null; + }, [item.session.results, item.session.source_prepared_mapping]); + + const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); + + const preloadOutputImageAndClearProgress = useCallback( + async (imageDTO: ImageDTO) => { try { await loadImage(imageDTO.image_url, true); clearProgressImage(ctx.$progressData, item.session_id); return; } catch { - // noop + // noop - but should we do something? means image failed to load... } - }, [ctx.$progressData, imageDTO, item.session_id]); + }, + [ctx.$progressData, item.session_id] + ); - useEffect(() => { - syncIsReady(); - }, [syncIsReady]); - - if (imageDTO && !hasProgressImage) { - return ( - - - {size === 'mini' && } - {size === 'full' && ( - <> - - - - )} - - ); + useEffect(() => { + if (!imageDTO) { + return; } + if (!ctx.$progressData.get()[item.session_id]?.progressImage) { + return; + } + preloadOutputImageAndClearProgress(imageDTO); + }, [ctx.$progressData, imageDTO, item.session_id, preloadOutputImageAndClearProgress]); + return imageDTO; +}; + +type MiniQueueItemProps = { + item: S['SessionQueueItem']; + number: number; + isSelected: boolean; + onSelectItemId: (item_id: number) => void; + onChangeAutoSwitch: (autoSwitch: boolean) => void; +}; + +const MiniQueueItem = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: MiniQueueItemProps) => { + const ctx = useStagingContext(); + const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); + const imageDTO = useOutputImageDTO(item); + + const onClick = useCallback(() => { + onSelectItemId(item.item_id); + }, [item.item_id, onSelectItemId]); + + const onDoubleClick = useCallback(() => { + onChangeAutoSwitch(item.status === 'in_progress'); + }, [item.status, onChangeAutoSwitch]); + + if (imageDTO && !hasProgressImage) { return ( - - - {size === 'mini' && } - {size === 'full' && ( - <> - - - - )} + + + ); } -); -QueueItemCard.displayName = 'QueueItemStatusCard'; + + return ( + + + + + ); +}); +MiniQueueItem.displayName = 'MiniQueueItem'; + +const fullSizeQueueItemSx = { + cursor: 'pointer', + pos: 'relative', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + h: 'full', + maxH: 'full', + maxW: 'full', + minW: 0, + minH: 0, +}; + +type FullSizeQueueItemProps = { + item: S['SessionQueueItem']; + number: number; +}; + +const FullSizeQueueItem = memo(({ item, number }: FullSizeQueueItemProps) => { + const ctx = useStagingContext(); + const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); + const imageDTO = useOutputImageDTO(item); + + if (imageDTO && !hasProgressImage) { + return ( + + + + + + ); + } + + return ( + + + + + + ); +}); +FullSizeQueueItem.displayName = 'FullSizeQueueItem'; const getMessage = (data: S['InvocationProgressEvent']) => { let message = data.message; @@ -859,6 +906,18 @@ const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & Butto }); ImageActions.displayName = 'ImageActions'; +const canvasBgSx = { + position: 'relative', + w: 'full', + h: 'full', + borderRadius: 'base', + overflow: 'hidden', + bg: 'base.900', + '&[data-dynamic-grid="true"]': { + bg: 'base.850', + }, +}; + const CanvasActiveSession = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); @@ -886,15 +945,7 @@ const CanvasActiveSession = memo(() => { renderMenu={renderMenu} withLongPress={false}> {(ref) => ( - + Date: Wed, 4 Jun 2025 14:24:03 +1000 Subject: [PATCH 038/210] feat: canvas flow rework (wip) --- .../OverlayScrollbars/ScrollableContent.tsx | 5 +- .../components/CanvasMainPanelContent.tsx | 492 +++++++++--------- 2 files changed, 244 insertions(+), 253 deletions(-) diff --git a/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx b/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx index d61a5e498c..5da75b10c6 100644 --- a/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx +++ b/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx @@ -11,13 +11,14 @@ import { memo, useEffect, useMemo, useState } from 'react'; type Props = PropsWithChildren & { maxHeight?: ChakraProps['maxHeight']; + maxWidth?: ChakraProps['maxWidth']; overflowX?: 'hidden' | 'scroll'; overflowY?: 'hidden' | 'scroll'; }; const styles: CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }; -const ScrollableContent = ({ children, maxHeight, overflowX = 'hidden', overflowY = 'scroll' }: Props) => { +const ScrollableContent = ({ children, maxHeight, maxWidth, overflowX = 'hidden', overflowY = 'scroll' }: Props) => { const overlayscrollbarsOptions = useMemo( () => getOverlayScrollbarsParams({ overflowX, overflowY }).options, [overflowX, overflowY] @@ -44,7 +45,7 @@ const ScrollableContent = ({ children, maxHeight, overflowX = 'hidden', overflow }, [os]); return ( - + {children} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 6692ee1c9e..de6bab9717 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,11 +1,17 @@ /* eslint-disable i18next/no-literal-string */ -import type { ButtonGroupProps, SystemStyleObject, TextProps } from '@invoke-ai/ui-library'; +import type { + ButtonGroupProps, + CircularProgressProps, + ImageProps, + SystemStyleObject, + TextProps, +} from '@invoke-ai/ui-library'; import { - Box, Button, ButtonGroup, CircularProgress, ContextMenu, + Divider, Flex, FormControl, FormLabel, @@ -26,7 +32,7 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; -import { IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; @@ -44,7 +50,6 @@ import { StagingAreaToolbar } from 'features/controlLayers/components/StagingAre import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { loadImage } from 'features/controlLayers/konva/util'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; import { canvasSessionStarted, selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; @@ -56,7 +61,7 @@ import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtil import { round } from 'lodash-es'; import { atom, type WritableAtom } from 'nanostores'; import type { ChangeEvent } from 'react'; -import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; @@ -64,7 +69,7 @@ import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; import type { ImageDTO, S } from 'services/api/types'; import type { ProgressData } from 'services/events/stores'; -import { $socket, clearProgressImage, setProgress, useHasProgressImage, useProgressData } from 'services/events/stores'; +import { $socket, setProgress, useProgressData } from 'services/events/stores'; import type { Equals, Param0 } from 'tsafe'; import { assert, objectEntries } from 'tsafe'; @@ -313,31 +318,6 @@ const GenerateWithStartingImageAndInpaintMask = memo(() => { }); GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask'; -const scrollIndicatorBaseSx = { - opacity: 0, - position: 'absolute', - w: 16, - h: 'full', - transitionProperty: 'opacity', - transitionDuration: '0.3s', - pointerEvents: 'none', - '&[data-visible="true"]': { - opacity: 1, - }, -} satisfies SystemStyleObject; - -const scrollIndicatorLeftSx = { - ...scrollIndicatorBaseSx, - left: 0, - bg: 'linear-gradient(to right, var(--invoke-colors-base-900), transparent)', -} satisfies SystemStyleObject; - -const scrollIndicatorRightSx = { - ...scrollIndicatorBaseSx, - right: 0, - bg: 'linear-gradient(to left, var(--invoke-colors-base-900), transparent)', -} satisfies SystemStyleObject; - type StagingContextValue = { session: | { @@ -423,39 +403,25 @@ const StagingArea = memo(() => { const ctx = useStagingContext(); const [selectedItemId, setSelectedItemId] = useState(null); const [autoSwitch, setAutoSwitch] = useState(true); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - const scrollableRef = useRef(null); const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS); - const selectedItem = useMemo( - () => - items.length > 0 && selectedItemId !== null ? items.find(({ item_id }) => item_id === selectedItemId) : null, - [items, selectedItemId] - ); - const selectedItemIndex = useMemo( - () => - items.length > 0 && selectedItemId !== null ? items.findIndex(({ item_id }) => item_id === selectedItemId) : null, - [items, selectedItemId] - ); - - useEffect(() => { - const el = scrollableRef.current; - if (!el) { - return; + const selectedItem = useMemo(() => { + if (items.length === 0) { + return null; } - const onScroll = () => { - const { scrollLeft, scrollWidth, clientWidth } = el; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft + clientWidth < scrollWidth); - }; - el.addEventListener('scroll', onScroll); - const observer = new ResizeObserver(onScroll); - observer.observe(el); - return () => { - el.removeEventListener('scroll', onScroll); - observer.disconnect(); - }; - }, []); + if (selectedItemId === null) { + return null; + } + return items.find(({ item_id }) => item_id === selectedItemId) ?? null; + }, [items, selectedItemId]); + const selectedItemIndex = useMemo(() => { + if (items.length === 0) { + return null; + } + if (selectedItemId === null) { + return null; + } + return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null; + }, [items, selectedItemId]); const onSelectItemId = useCallback((item_id: number | null) => { setSelectedItemId(item_id); @@ -466,10 +432,6 @@ const StagingArea = memo(() => { useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId); - const onChangeAutoSwitch = useCallback((autoSwitch: boolean) => { - setAutoSwitch(autoSwitch); - }, []); - useEffect(() => { if (items.length === 0) { onSelectItemId(null); @@ -503,10 +465,6 @@ const StagingArea = memo(() => { }; }, [autoSwitch, ctx.$progressData, ctx.session.id, onSelectItemId, socket]); - const _onChangeAutoSwitch = useCallback((e: ChangeEvent) => { - setAutoSwitch(e.target.checked); - }, []); - useEffect(() => { if (!socket) { return; @@ -526,9 +484,47 @@ const StagingArea = memo(() => { return ( - - - + + + {items.length > 0 && ( + + )} + {items.length === 0 && ( + + No generations + + )} + + ); +}); +StagingArea.displayName = 'StagingArea'; + +const StagingAreaContent = memo( + ({ + items, + selectedItem, + selectedItemId, + selectedItemIndex, + onChangeAutoSwitch, + onSelectItemId, + }: { + items: S['SessionQueueItem'][]; + selectedItem: S['SessionQueueItem'] | null; + selectedItemId: number | null; + selectedItemIndex: number | null; + onChangeAutoSwitch: (autoSwitch: boolean) => void; + onSelectItemId: (itemId: number) => void; + }) => { + return ( + <> + {selectedItem && selectedItemIndex !== null && ( { )} {!selectedItem && No generation selected} - - Auto-switch - - - - - - {items.map((item, i) => ( - - ))} + + + + + {items.map((item, i) => ( + + ))} + + - - + + ); + } +); +StagingAreaContent.displayName = 'StagingAreaContent'; + +const StagingAreaHeader = memo( + ({ autoSwitch, setAutoSwitch }: { autoSwitch: boolean; setAutoSwitch: (autoSwitch: boolean) => void }) => { + const dispatch = useAppDispatch(); + + const startOver = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: 'simple' })); + }, [dispatch]); + + const onChangeAutoSwitch = useCallback( + (e: ChangeEvent) => { + setAutoSwitch(e.target.checked); + }, + [setAutoSwitch] + ); + + return ( + + + Generations + + + + Auto-switch + + + - - ); -}); -StagingArea.displayName = 'StagingArea'; - -const StagingAreaHeader = memo(() => { - const dispatch = useAppDispatch(); - - const startOver = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); - }, [dispatch]); - - return ( - - - Generations - - - - - ); -}); + ); + } +); StagingAreaHeader.displayName = 'StagingAreaHeader'; const miniQueueItemSx = { cursor: 'pointer', + userSelect: 'none', pos: 'relative', alignItems: 'center', justifyContent: 'center', @@ -603,57 +610,34 @@ const miniQueueItemSx = { }, aspectRatio: '1/1', flexShrink: 0, -}; +} satisfies SystemStyleObject; const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`; -const useOutputImageDTO = (item: S['SessionQueueItem']) => { - const ctx = useStagingContext(); - - const outputImageName = useMemo(() => { - const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => - isCanvasOutputNodeId(nodeId) - )?.[1][0]; - const output = nodeId ? item.session.results[nodeId] : undefined; - - if (!output) { - return null; - } - - for (const [_name, value] of objectEntries(output)) { - if (isImageField(value)) { - return value.image_name; - } - } +const getOutputImageName = (item: S['SessionQueueItem']) => { + const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => + isCanvasOutputNodeId(nodeId) + )?.[1][0]; + const output = nodeId ? item.session.results[nodeId] : undefined; + if (!output) { return null; - }, [item.session.results, item.session.source_prepared_mapping]); + } + + for (const [_name, value] of objectEntries(output)) { + if (isImageField(value)) { + return value.image_name; + } + } + + return null; +}; + +const useOutputImageDTO = (item: S['SessionQueueItem']) => { + const outputImageName = useMemo(() => getOutputImageName(item), [item]); const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); - const preloadOutputImageAndClearProgress = useCallback( - async (imageDTO: ImageDTO) => { - try { - await loadImage(imageDTO.image_url, true); - clearProgressImage(ctx.$progressData, item.session_id); - return; - } catch { - // noop - but should we do something? means image failed to load... - } - }, - [ctx.$progressData, item.session_id] - ); - - useEffect(() => { - if (!imageDTO) { - return; - } - if (!ctx.$progressData.get()[item.session_id]?.progressImage) { - return; - } - preloadOutputImageAndClearProgress(imageDTO); - }, [ctx.$progressData, imageDTO, item.session_id, preloadOutputImageAndClearProgress]); - return imageDTO; }; @@ -666,8 +650,7 @@ type MiniQueueItemProps = { }; const MiniQueueItem = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: MiniQueueItemProps) => { - const ctx = useStagingContext(); - const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); + const [imageLoaded, setImageLoaded] = useState(false); const imageDTO = useOutputImageDTO(item); const onClick = useCallback(() => { @@ -678,14 +661,9 @@ const MiniQueueItem = memo(({ item, isSelected, number, onSelectItemId, onChange onChangeAutoSwitch(item.status === 'in_progress'); }, [item.status, onChangeAutoSwitch]); - if (imageDTO && !hasProgressImage) { - return ( - - - - - ); - } + const onLoad = useCallback(() => { + setImageLoaded(true); + }, []); return ( - + + {imageDTO && } + {!imageLoaded && } + ); }); @@ -704,16 +685,14 @@ MiniQueueItem.displayName = 'MiniQueueItem'; const fullSizeQueueItemSx = { cursor: 'pointer', + userSelect: 'none', pos: 'relative', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', h: 'full', - maxH: 'full', - maxW: 'full', - minW: 0, - minH: 0, -}; + w: 'full', +} satisfies SystemStyleObject; type FullSizeQueueItemProps = { item: S['SessionQueueItem']; @@ -721,30 +700,48 @@ type FullSizeQueueItemProps = { }; const FullSizeQueueItem = memo(({ item, number }: FullSizeQueueItemProps) => { - const ctx = useStagingContext(); - const hasProgressImage = useHasProgressImage(ctx.$progressData, item.session_id); const imageDTO = useOutputImageDTO(item); + const [imageLoaded, setImageLoaded] = useState(false); - if (imageDTO && !hasProgressImage) { - return ( - - - - - - ); - } + const onLoad = useCallback(() => { + setImageLoaded(true); + }, []); return ( - + + {imageDTO && } + {!imageLoaded && } - + + ); }); FullSizeQueueItem.displayName = 'FullSizeQueueItem'; +const ProgressImage = memo(({ session_id, ...rest }: { session_id: string } & ImageProps) => { + const { $progressData } = useStagingContext(); + const { progressImage } = useProgressData($progressData, session_id); + + if (!progressImage) { + return null; + } + + return ( + + ); +}); +ProgressImage.displayName = 'ProgressImage'; + const getMessage = (data: S['InvocationProgressEvent']) => { let message = data.message; if (data.percentage) { @@ -758,79 +755,58 @@ const ItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => }); ItemNumber.displayName = 'ItemNumber'; -const ProgressMessage = memo(({ session_id, ...rest }: { session_id: string } & TextProps) => { - const { $progressData } = useStagingContext(); - const { progressEvent } = useProgressData($progressData, session_id); - if (!progressEvent) { - return null; +const ProgressMessage = memo( + ({ session_id, status, ...rest }: { session_id: string; status: S['SessionQueueItem']['status'] } & TextProps) => { + const { $progressData } = useStagingContext(); + const { progressEvent } = useProgressData($progressData, session_id); + + if (status === 'completed' || status === 'failed' || status === 'canceled') { + return null; + } + + return ( + + {progressEvent ? getMessage(progressEvent) : 'Waiting to start...'} + + ); } - return ( - - {getMessage(progressEvent)} - - ); -}); +); ProgressMessage.displayName = 'ProgressMessage'; -const InProgressContent = memo(({ item }: { item: S['SessionQueueItem'] }) => { - const { $progressData } = useStagingContext(); - const { progressEvent, progressImage } = useProgressData($progressData, item.session_id); - - if (item.status === 'pending') { +const ProgressLabel = memo(({ status, ...rest }: { status: S['SessionQueueItem']['status'] } & TextProps) => { + if (status === 'pending') { return ( - + Pending ); } - if (item.status === 'canceled') { + if (status === 'canceled') { return ( - + Canceled ); } - if (item.status === 'failed') { + if (status === 'failed') { return ( - + Failed ); } - if (progressImage) { + if (status === 'in_progress') { return ( - <> - - - + + In Progress + ); } - if (item.status === 'in_progress') { - return ( - <> - - In Progress - - - - ); - } - - if (item.status === 'completed') { - return ; - } - assert>(false); + return null; }); -InProgressContent.displayName = 'InProgressContent'; +ProgressLabel.displayName = 'ProgressLabel'; const circleStyles: SystemStyleObject = { circle: { @@ -842,20 +818,34 @@ const circleStyles: SystemStyleObject = { right: 2, }; -const ProgressCircle = memo(({ data }: { data?: S['InvocationProgressEvent'] | null }) => { - return ( - - - - ); -}); +const ProgressCircle = memo( + ({ + session_id, + status, + ...rest + }: { session_id: string; status: S['SessionQueueItem']['status'] } & CircularProgressProps) => { + const { $progressData } = useStagingContext(); + const { progressEvent } = useProgressData($progressData, session_id); + + if (status !== 'in_progress') { + return null; + } + + return ( + + + + ); + } +); ProgressCircle.displayName = 'ProgressCircle'; const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => { From cd136194ad07533f617d2711d8f7307f9a818b77 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:30:02 +1000 Subject: [PATCH 039/210] fix(ui): prevent drag of progress images --- .../features/controlLayers/components/CanvasMainPanelContent.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index de6bab9717..b0ba682988 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -733,6 +733,7 @@ const ProgressImage = memo(({ session_id, ...rest }: { session_id: string } & Im objectFit="contain" maxH="full" maxW="full" + draggable={false} src={progressImage.dataURL} width={progressImage.width} height={progressImage.height} From 985cd8272bcf2a8ae640c1dc4d88aaed5306b07b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:30:01 +1000 Subject: [PATCH 040/210] tidy(ui): component organization --- .../AdvancedSession/AdvancedSession.tsx | 130 +++ .../components/CanvasMainPanelContent.tsx | 986 +----------------- .../NoSession/GenerateWithControlImage.tsx | 68 ++ .../NoSession/GenerateWithStartingImage.tsx | 65 ++ ...enerateWithStartingImageAndInpaintMask.tsx | 65 ++ .../components/NoSession/NoSession.tsx | 32 + .../components/SimpleSession/ImageActions.tsx | 55 + .../QueueItemCircularProgress.tsx | 46 + .../SimpleSession/QueueItemNumber.tsx | 9 + .../SimpleSession/QueueItemPreviewFull.tsx | 62 ++ .../SimpleSession/QueueItemPreviewMini.tsx | 79 ++ .../SimpleSession/QueueItemProgressImage.tsx | 28 + .../QueueItemProgressMessage.tsx | 34 + .../SimpleSession/QueueItemStatusLabel.tsx | 42 + .../SimpleSession/SimpleSession.tsx | 22 + .../components/SimpleSession/StagingArea.tsx | 130 +++ .../SimpleSession/StagingAreaContent.tsx | 58 ++ .../SimpleSession/StagingAreaHeader.tsx | 40 + .../components/SimpleSession/context.tsx | 135 +++ .../components/SimpleSession/shared.ts | 51 + .../SimpleSession/use-staging-keyboard-nav.ts | 54 + .../store/canvasStagingAreaSlice.ts | 12 +- 22 files changed, 1227 insertions(+), 976 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImage.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImageAndInpaintMask.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx new file mode 100644 index 0000000000..73f50ca36f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx @@ -0,0 +1,130 @@ +/* eslint-disable i18next/no-literal-string */ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { ContextMenu, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; +import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; +import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; +import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; +import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems'; +import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems'; +import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea'; +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 { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate'; +import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; +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 type { AdvancedSessionIdentifier } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { memo, useCallback } from 'react'; +import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; + +const FOCUS_REGION_STYLES: SystemStyleObject = { + width: 'full', + height: 'full', +}; + +const MenuContent = memo(() => { + return ( + + + + + + + ); +}); +MenuContent.displayName = 'MenuContent'; + +const canvasBgSx = { + position: 'relative', + w: 'full', + h: 'full', + borderRadius: 'base', + overflow: 'hidden', + bg: 'base.900', + '&[data-dynamic-grid="true"]': { + bg: 'base.850', + }, +}; + +export const AdvancedSession = memo((_props: { session: AdvancedSessionIdentifier }) => { + const dynamicGrid = useAppSelector(selectDynamicGrid); + const showHUD = useAppSelector(selectShowHUD); + + const renderMenu = useCallback(() => { + return ; + }, []); + + return ( + + + + + + renderMenu={renderMenu} withLongPress={false}> + {(ref) => ( + + + + + {showHUD && } + + + + + + + } colorScheme="base" /> + + + + + + )} + + + + + + + + + + + + + + + + + + + + + ); +}); +AdvancedSession.displayName = 'AdvancedSession'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index b0ba682988..98b7bf3a5b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,991 +1,27 @@ -/* eslint-disable i18next/no-literal-string */ -import type { - ButtonGroupProps, - CircularProgressProps, - ImageProps, - SystemStyleObject, - TextProps, -} from '@invoke-ai/ui-library'; -import { - Button, - ButtonGroup, - CircularProgress, - ContextMenu, - Divider, - Flex, - FormControl, - FormLabel, - Heading, - IconButton, - Image, - Menu, - MenuButton, - MenuList, - Spacer, - Switch, - Text, - Tooltip, -} from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { skipToken } from '@reduxjs/toolkit/query'; -import { EMPTY_ARRAY } from 'app/store/constants'; -import { useAppStore } from 'app/store/nanostores/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; -import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; -import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; -import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; -import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo'; -import { CanvasBusySpinner } from 'features/controlLayers/components/CanvasBusySpinner'; -import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems'; -import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems'; -import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea'; -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 { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate'; -import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; -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 { canvasSessionStarted, selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; -import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import { DndImage } from 'features/dnd/DndImage'; -import { newCanvasFromImage } from 'features/imageActions/actions'; -import { isImageField } from 'features/nodes/types/common'; -import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; -import { round } from 'lodash-es'; -import { atom, type WritableAtom } from 'nanostores'; -import type { ChangeEvent } from 'react'; -import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { Trans, useTranslation } from 'react-i18next'; -import { PiDotsThreeOutlineVerticalFill, PiUploadBold } from 'react-icons/pi'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; -import type { ImageDTO, S } from 'services/api/types'; -import type { ProgressData } from 'services/events/stores'; -import { $socket, setProgress, useProgressData } from 'services/events/stores'; -import type { Equals, Param0 } from 'tsafe'; -import { assert, objectEntries } from 'tsafe'; - -import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress'; - -const FOCUS_REGION_STYLES: SystemStyleObject = { - width: 'full', - height: 'full', -}; - -const MenuContent = memo(() => { - return ( - - - - - - - ); -}); -MenuContent.displayName = 'MenuContent'; +import { useAppSelector } from 'app/store/storeHooks'; +import { AdvancedSession } from 'features/controlLayers/components/AdvancedSession/AdvancedSession'; +import { NoSession } from 'features/controlLayers/components/NoSession/NoSession'; +import { SimpleSession } from 'features/controlLayers/components/SimpleSession/SimpleSession'; +import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { memo } from 'react'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; export const CanvasMainPanelContent = memo(() => { const session = useAppSelector(selectCanvasSession); if (session === null) { - return ; + return ; } if (session.type === 'simple') { - return ; + return ; } if (session.type === 'advanced') { - return ; + return ; } assert>(false, 'Unexpected session'); }); CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; - -const StagingAreaWrapper = memo(({ id }: { id: string }) => { - const ctx = useMemo( - () => - ({ - session: { - type: 'simple', - id, - }, - $progressData: atom>({}), - }) as const, - [id] - ); - - return ( - - - - ); -}); -StagingAreaWrapper.displayName = 'StagingAreaWrapper'; - -const generateWithStartingImageDndTargetData = newCanvasFromImageDndTarget.getData({ - type: 'raster_layer', - withResize: true, -}); -const generateWithStartingImageAndInpaintMaskDndTargetData = newCanvasFromImageDndTarget.getData({ - type: 'raster_layer', - withInpaintMask: true, -}); -const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getData({ - type: 'control_layer', - withResize: true, -}); - -const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))'; - -const NoActiveSession = memo(() => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const newSesh = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); - }, [dispatch]); - - return ( - - Get Started with Invoke - - or - - - - - - - ); -}); -NoActiveSession.displayName = 'NoActiveSession'; - -const GenerateWithStartingImage = memo(() => { - const { t } = useTranslation(); - const { getState, dispatch } = useAppStore(); - const useImageUploadButtonOptions = useMemo>( - () => ({ - onUpload: (imageDTO: ImageDTO) => { - newCanvasFromImage({ imageDTO, type: 'raster_layer', withResize: true, getState, dispatch }); - }, - allowMultiple: false, - }), - [dispatch, getState] - ); - const uploadApi = useImageUploadButton(useImageUploadButtonOptions); - const components = useMemo( - () => ({ - UploadButton: ( - - - ); - } -); -StagingAreaHeader.displayName = 'StagingAreaHeader'; - -const miniQueueItemSx = { - cursor: 'pointer', - userSelect: 'none', - pos: 'relative', - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - h: 'full', - maxH: 'full', - maxW: 'full', - minW: 0, - minH: 0, - borderWidth: 1, - borderRadius: 'base', - '&[data-selected="true"]': { - borderColor: 'invokeBlue.300', - }, - aspectRatio: '1/1', - flexShrink: 0, -} satisfies SystemStyleObject; - -const getCardId = (item_id: number) => `queue-item-status-card-${item_id}`; - -const getOutputImageName = (item: S['SessionQueueItem']) => { - const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => - isCanvasOutputNodeId(nodeId) - )?.[1][0]; - const output = nodeId ? item.session.results[nodeId] : undefined; - - if (!output) { - return null; - } - - for (const [_name, value] of objectEntries(output)) { - if (isImageField(value)) { - return value.image_name; - } - } - - return null; -}; - -const useOutputImageDTO = (item: S['SessionQueueItem']) => { - const outputImageName = useMemo(() => getOutputImageName(item), [item]); - - const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); - - return imageDTO; -}; - -type MiniQueueItemProps = { - item: S['SessionQueueItem']; - number: number; - isSelected: boolean; - onSelectItemId: (item_id: number) => void; - onChangeAutoSwitch: (autoSwitch: boolean) => void; -}; - -const MiniQueueItem = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: MiniQueueItemProps) => { - const [imageLoaded, setImageLoaded] = useState(false); - const imageDTO = useOutputImageDTO(item); - - const onClick = useCallback(() => { - onSelectItemId(item.item_id); - }, [item.item_id, onSelectItemId]); - - const onDoubleClick = useCallback(() => { - onChangeAutoSwitch(item.status === 'in_progress'); - }, [item.status, onChangeAutoSwitch]); - - const onLoad = useCallback(() => { - setImageLoaded(true); - }, []); - - return ( - - - {imageDTO && } - {!imageLoaded && } - - - - ); -}); -MiniQueueItem.displayName = 'MiniQueueItem'; - -const fullSizeQueueItemSx = { - cursor: 'pointer', - userSelect: 'none', - pos: 'relative', - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - h: 'full', - w: 'full', -} satisfies SystemStyleObject; - -type FullSizeQueueItemProps = { - item: S['SessionQueueItem']; - number: number; -}; - -const FullSizeQueueItem = memo(({ item, number }: FullSizeQueueItemProps) => { - const imageDTO = useOutputImageDTO(item); - const [imageLoaded, setImageLoaded] = useState(false); - - const onLoad = useCallback(() => { - setImageLoaded(true); - }, []); - - return ( - - - {imageDTO && } - {!imageLoaded && } - - - - - ); -}); -FullSizeQueueItem.displayName = 'FullSizeQueueItem'; - -const ProgressImage = memo(({ session_id, ...rest }: { session_id: string } & ImageProps) => { - const { $progressData } = useStagingContext(); - const { progressImage } = useProgressData($progressData, session_id); - - if (!progressImage) { - return null; - } - - return ( - - ); -}); -ProgressImage.displayName = 'ProgressImage'; - -const getMessage = (data: S['InvocationProgressEvent']) => { - let message = data.message; - if (data.percentage) { - message += ` (${round(data.percentage * 100)}%)`; - } - return message; -}; - -const ItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => { - return {`#${number}`}; -}); -ItemNumber.displayName = 'ItemNumber'; - -const ProgressMessage = memo( - ({ session_id, status, ...rest }: { session_id: string; status: S['SessionQueueItem']['status'] } & TextProps) => { - const { $progressData } = useStagingContext(); - const { progressEvent } = useProgressData($progressData, session_id); - - if (status === 'completed' || status === 'failed' || status === 'canceled') { - return null; - } - - return ( - - {progressEvent ? getMessage(progressEvent) : 'Waiting to start...'} - - ); - } -); -ProgressMessage.displayName = 'ProgressMessage'; - -const ProgressLabel = memo(({ status, ...rest }: { status: S['SessionQueueItem']['status'] } & TextProps) => { - if (status === 'pending') { - return ( - - Pending - - ); - } - if (status === 'canceled') { - return ( - - Canceled - - ); - } - if (status === 'failed') { - return ( - - Failed - - ); - } - - if (status === 'in_progress') { - return ( - - In Progress - - ); - } - - return null; -}); -ProgressLabel.displayName = 'ProgressLabel'; - -const circleStyles: SystemStyleObject = { - circle: { - transitionProperty: 'none', - transitionDuration: '0s', - }, - position: 'absolute', - top: 2, - right: 2, -}; - -const ProgressCircle = memo( - ({ - session_id, - status, - ...rest - }: { session_id: string; status: S['SessionQueueItem']['status'] } & CircularProgressProps) => { - const { $progressData } = useStagingContext(); - const { progressEvent } = useProgressData($progressData, session_id); - - if (status !== 'in_progress') { - return null; - } - - return ( - - - - ); - } -); -ProgressCircle.displayName = 'ProgressCircle'; - -const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => { - const { getState, dispatch } = useAppStore(); - - const vary = useCallback(() => { - newCanvasFromImage({ - imageDTO, - type: 'raster_layer', - withResize: true, - getState, - dispatch, - }); - }, [dispatch, getState, imageDTO]); - - const useAsControl = useCallback(() => { - newCanvasFromImage({ - imageDTO, - type: 'control_layer', - withResize: true, - getState, - dispatch, - }); - }, [dispatch, getState, imageDTO]); - - const edit = useCallback(() => { - newCanvasFromImage({ - imageDTO, - type: 'raster_layer', - withInpaintMask: true, - getState, - dispatch, - }); - }, [dispatch, getState, imageDTO]); - return ( - - - - - - ); -}); -ImageActions.displayName = 'ImageActions'; - -const canvasBgSx = { - position: 'relative', - w: 'full', - h: 'full', - borderRadius: 'base', - overflow: 'hidden', - bg: 'base.900', - '&[data-dynamic-grid="true"]': { - bg: 'base.850', - }, -}; - -const CanvasActiveSession = memo(() => { - const dynamicGrid = useAppSelector(selectDynamicGrid); - const showHUD = useAppSelector(selectShowHUD); - - const renderMenu = useCallback(() => { - return ; - }, []); - - return ( - - - - - - renderMenu={renderMenu} withLongPress={false}> - {(ref) => ( - - - - - {showHUD && } - - - - - - - - } colorScheme="base" /> - - - - - - - - - )} - - - - - - - - - - - - - - - - - - - - - ); -}); -CanvasActiveSession.displayName = 'ActiveCanvasContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx new file mode 100644 index 0000000000..14b253330c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx @@ -0,0 +1,68 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Button, Flex, Text } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { newCanvasFromImage } from 'features/imageActions/actions'; +import { memo, useMemo } from 'react'; +import { Trans } from 'react-i18next'; +import { PiUploadBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; +import type { Param0 } from 'tsafe'; + +const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getData({ + type: 'control_layer', + withResize: true, +}); + +export const GenerateWithControlImage = memo(() => { + const { getState, dispatch } = useAppStore(); + const useImageUploadButtonOptions = useMemo>( + () => ({ + onUpload: (imageDTO: ImageDTO) => { + newCanvasFromImage({ imageDTO, type: 'control_layer', withResize: true, getState, dispatch }); + }, + allowMultiple: false, + }), + [dispatch, getState] + ); + const uploadApi = useImageUploadButton(useImageUploadButtonOptions); + const components = useMemo( + () => ({ + UploadButton: ( + + or + + + + + + + ); +}); +NoSession.displayName = 'NoSession'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx new file mode 100644 index 0000000000..96d27b8d14 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx @@ -0,0 +1,55 @@ +/* eslint-disable i18next/no-literal-string */ +import type { ButtonGroupProps } from '@invoke-ai/ui-library'; +import { Button, ButtonGroup } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { newCanvasFromImage } from 'features/imageActions/actions'; +import { memo, useCallback } from 'react'; +import type { ImageDTO } from 'services/api/types'; + +export const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => { + const { getState, dispatch } = useAppStore(); + + const vary = useCallback(() => { + newCanvasFromImage({ + imageDTO, + type: 'raster_layer', + withResize: true, + getState, + dispatch, + }); + }, [dispatch, getState, imageDTO]); + + const useAsControl = useCallback(() => { + newCanvasFromImage({ + imageDTO, + type: 'control_layer', + withResize: true, + getState, + dispatch, + }); + }, [dispatch, getState, imageDTO]); + + const edit = useCallback(() => { + newCanvasFromImage({ + imageDTO, + type: 'raster_layer', + withInpaintMask: true, + getState, + dispatch, + }); + }, [dispatch, getState, imageDTO]); + return ( + + + + + + ); +}); +ImageActions.displayName = 'ImageActions'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx new file mode 100644 index 0000000000..ebf32f8082 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx @@ -0,0 +1,46 @@ +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'; + +const circleStyles: SystemStyleObject = { + circle: { + transitionProperty: 'none', + transitionDuration: '0s', + }, + position: 'absolute', + top: 2, + right: 2, +}; + +export const QueueItemCircularProgress = memo( + ({ + session_id, + status, + ...rest + }: { session_id: string; status: S['SessionQueueItem']['status'] } & CircularProgressProps) => { + const { $progressData } = useCanvasSessionContext(); + const { progressEvent } = useProgressData($progressData, session_id); + + if (status !== 'in_progress') { + return null; + } + + return ( + + + + ); + } +); +QueueItemCircularProgress.displayName = 'QueueItemCircularProgress'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx new file mode 100644 index 0000000000..33686a5c83 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemNumber.tsx @@ -0,0 +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'; + +export const QueueItemNumber = memo(({ number, ...rest }: { number: number } & TextProps) => { + return {`#${number}`}; +}); +QueueItemNumber.displayName = 'QueueItemNumber'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx new file mode 100644 index 0000000000..cc5085ba2a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx @@ -0,0 +1,62 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; +import { ImageActions } from 'features/controlLayers/components/SimpleSession/ImageActions'; +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 { QueueItemProgressMessage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressMessage'; +import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; +import { getQueueItemElementId, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/shared'; +import { DndImage } from 'features/dnd/DndImage'; +import { memo, useCallback, useState } from 'react'; +import type { S } from 'services/api/types'; + +type Props = { + item: S['SessionQueueItem']; + number: number; +}; + +const sx = { + cursor: 'pointer', + userSelect: 'none', + pos: 'relative', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + h: 'full', + w: 'full', +} satisfies SystemStyleObject; + +export const QueueItemPreviewFull = memo(({ item, number }: Props) => { + const imageDTO = useOutputImageDTO(item); + const [imageLoaded, setImageLoaded] = useState(false); + + const onLoad = useCallback(() => { + setImageLoaded(true); + }, []); + + return ( + + + {imageDTO && } + {!imageLoaded && } + {imageDTO && imageLoaded && } + + + + + ); +}); +QueueItemPreviewFull.displayName = 'QueueItemPreviewFull'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx new file mode 100644 index 0000000000..ffecb9c9b8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -0,0 +1,79 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; +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, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/shared'; +import { DndImage } from 'features/dnd/DndImage'; +import { memo, useCallback, useState } from 'react'; +import type { S } from 'services/api/types'; + +const sx = { + cursor: 'pointer', + userSelect: 'none', + pos: 'relative', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + h: 'full', + maxH: 'full', + maxW: 'full', + minW: 0, + minH: 0, + borderWidth: 1, + borderRadius: 'base', + '&[data-selected="true"]': { + borderColor: 'invokeBlue.300', + }, + aspectRatio: '1/1', + flexShrink: 0, +} satisfies SystemStyleObject; + +type Props = { + item: S['SessionQueueItem']; + number: number; + isSelected: boolean; + onSelectItemId: (item_id: number) => void; + onChangeAutoSwitch: (autoSwitch: boolean) => void; +}; + +export const QueueItemPreviewMini = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: Props) => { + const [imageLoaded, setImageLoaded] = useState(false); + const imageDTO = useOutputImageDTO(item); + + const onClick = useCallback(() => { + onSelectItemId(item.item_id); + }, [item.item_id, onSelectItemId]); + + const onDoubleClick = useCallback(() => { + onChangeAutoSwitch(item.status === 'in_progress'); + }, [item.status, onChangeAutoSwitch]); + + const onLoad = useCallback(() => { + setImageLoaded(true); + }, []); + + return ( + + + {imageDTO && } + {!imageLoaded && } + + + + ); +}); +QueueItemPreviewMini.displayName = 'QueueItemPreviewMini'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx new file mode 100644 index 0000000000..55e8d836b7 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -0,0 +1,28 @@ +import type { ImageProps } from '@invoke-ai/ui-library'; +import { Image } from '@invoke-ai/ui-library'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { memo } from 'react'; +import { useProgressData } from 'services/events/stores'; + +export const QueueItemProgressImage = memo(({ session_id, ...rest }: { session_id: string } & ImageProps) => { + const { $progressData } = useCanvasSessionContext(); + const { progressImage } = useProgressData($progressData, session_id); + + if (!progressImage) { + return null; + } + + return ( + + ); +}); +QueueItemProgressImage.displayName = 'QueueItemProgressImage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx new file mode 100644 index 0000000000..d287811fb3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx @@ -0,0 +1,34 @@ +/* eslint-disable i18next/no-literal-string */ +import type { TextProps } from '@invoke-ai/ui-library'; +import { Text } from '@invoke-ai/ui-library'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { DROP_SHADOW, getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared'; +import { memo } from 'react'; +import type { S } from 'services/api/types'; +import { useProgressData } from 'services/events/stores'; + +export const QueueItemProgressMessage = memo( + ({ session_id, status, ...rest }: { session_id: string; status: S['SessionQueueItem']['status'] } & TextProps) => { + const { $progressData } = useCanvasSessionContext(); + const { progressEvent } = useProgressData($progressData, session_id); + + if (status === 'completed' || status === 'failed' || status === 'canceled') { + return null; + } + + if (status === 'pending') { + return ( + + Waiting to start... + + ); + } + + return ( + + {getProgressMessage(progressEvent)} + + ); + } +); +QueueItemProgressMessage.displayName = 'QueueItemProgressMessage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx new file mode 100644 index 0000000000..5924b63981 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx @@ -0,0 +1,42 @@ +/* eslint-disable i18next/no-literal-string */ +import type { TextProps } from '@invoke-ai/ui-library'; +import { Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import type { S } from 'services/api/types'; + +export const QueueItemStatusLabel = memo( + ({ status, ...rest }: { status: S['SessionQueueItem']['status'] } & TextProps) => { + if (status === 'pending') { + return ( + + Pending + + ); + } + if (status === 'canceled') { + return ( + + Canceled + + ); + } + if (status === 'failed') { + return ( + + Failed + + ); + } + + if (status === 'in_progress') { + return ( + + In Progress + + ); + } + + return null; + } +); +QueueItemStatusLabel.displayName = 'QueueItemStatusLabel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx new file mode 100644 index 0000000000..86d14450d2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx @@ -0,0 +1,22 @@ +import type { CanvasSessionContextValue } from 'features/controlLayers/components/SimpleSession/context'; +import { + buildProgressDataAtom, + CanvasSessionContextProvider, +} from 'features/controlLayers/components/SimpleSession/context'; +import { StagingArea } from 'features/controlLayers/components/SimpleSession/StagingArea'; +import type { SimpleSessionIdentifier } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { memo, useMemo } from 'react'; + +export const SimpleSession = memo(({ session }: { session: SimpleSessionIdentifier }) => { + const ctx = useMemo( + () => ({ session, $progressData: buildProgressDataAtom() }) satisfies CanvasSessionContextValue, + [session] + ); + + return ( + + + + ); +}); +SimpleSession.displayName = 'SimpleSession'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx new file mode 100644 index 0000000000..8eef21c9fd --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx @@ -0,0 +1,130 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Divider, Flex, Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; +import { StagingAreaContent } from 'features/controlLayers/components/SimpleSession/StagingAreaContent'; +import { StagingAreaHeader } from 'features/controlLayers/components/SimpleSession/StagingAreaHeader'; +import { useStagingAreaKeyboardNav } from 'features/controlLayers/components/SimpleSession/use-staging-keyboard-nav'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; +import type { S } from 'services/api/types'; +import { $socket, setProgress } from 'services/events/stores'; + +const LIST_ALL_OPTIONS = { + selectFromResult: ({ data }) => { + if (!data) { + return { items: EMPTY_ARRAY }; + } + return { items: data.filter(({ status }) => status !== 'canceled') }; + }, +} satisfies Parameters[1]; + +export const StagingArea = memo(() => { + const ctx = useCanvasSessionContext(); + const [selectedItemId, setSelectedItemId] = useState(null); + const [autoSwitch, setAutoSwitch] = useState(true); + const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS); + const selectedItem = useMemo(() => { + if (items.length === 0) { + return null; + } + if (selectedItemId === null) { + return null; + } + return items.find(({ item_id }) => item_id === selectedItemId) ?? null; + }, [items, selectedItemId]); + const selectedItemIndex = useMemo(() => { + if (items.length === 0) { + return null; + } + if (selectedItemId === null) { + return null; + } + return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null; + }, [items, selectedItemId]); + + const onSelectItemId = useCallback((item_id: number | null) => { + setSelectedItemId(item_id); + if (item_id !== null) { + document.getElementById(getQueueItemElementId(item_id))?.scrollIntoView(); + } + }, []); + + useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId); + + useEffect(() => { + if (items.length === 0) { + onSelectItemId(null); + return; + } + if (selectedItemId === null && items.length > 0) { + onSelectItemId(items[0]?.item_id ?? null); + return; + } + }, [items, onSelectItemId, selectedItem, selectedItemId]); + + const socket = useStore($socket); + useEffect(() => { + if (!socket) { + return; + } + + const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { + if (data.destination !== ctx.session.id) { + return; + } + if (data.status === 'in_progress' && autoSwitch) { + onSelectItemId(data.item_id); + } + }; + + socket.on('queue_item_status_changed', onQueueItemStatusChanged); + + return () => { + socket.off('queue_item_status_changed', onQueueItemStatusChanged); + }; + }, [autoSwitch, ctx.$progressData, ctx.session.id, onSelectItemId, socket]); + + useEffect(() => { + if (!socket) { + return; + } + const onProgress = (data: S['InvocationProgressEvent']) => { + if (data.destination !== ctx.session.id) { + return; + } + setProgress(ctx.$progressData, data); + }; + socket.on('invocation_progress', onProgress); + + return () => { + socket.off('invocation_progress', onProgress); + }; + }, [ctx.$progressData, ctx.session.id, socket]); + + return ( + + + + {items.length > 0 && ( + + )} + {items.length === 0 && ( + + No generations + + )} + + ); +}); +StagingArea.displayName = 'StagingArea'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx new file mode 100644 index 0000000000..ce1e29b3b2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx @@ -0,0 +1,58 @@ +/* eslint-disable i18next/no-literal-string */ +import { Divider, Flex, Text } from '@invoke-ai/ui-library'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { QueueItemPreviewFull } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewFull'; +import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini'; +import { memo } from 'react'; +import type { S } from 'services/api/types'; + +export const StagingAreaContent = memo( + ({ + items, + selectedItem, + selectedItemId, + selectedItemIndex, + onChangeAutoSwitch, + onSelectItemId, + }: { + items: S['SessionQueueItem'][]; + selectedItem: S['SessionQueueItem'] | null; + selectedItemId: number | null; + selectedItemIndex: number | null; + onChangeAutoSwitch: (autoSwitch: boolean) => void; + onSelectItemId: (itemId: number) => void; + }) => { + return ( + <> + + {selectedItem && selectedItemIndex !== null && ( + + )} + {!selectedItem && No generation selected} + + + + + + {items.map((item, i) => ( + + ))} + + + + + ); + } +); +StagingAreaContent.displayName = 'StagingAreaContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx new file mode 100644 index 0000000000..f9e04c1ef4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -0,0 +1,40 @@ +/* eslint-disable i18next/no-literal-string */ +import { Button, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; + +export const StagingAreaHeader = memo( + ({ autoSwitch, setAutoSwitch }: { autoSwitch: boolean; setAutoSwitch: (autoSwitch: boolean) => void }) => { + const dispatch = useAppDispatch(); + + const startOver = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: 'simple' })); + }, [dispatch]); + + const onChangeAutoSwitch = useCallback( + (e: ChangeEvent) => { + setAutoSwitch(e.target.checked); + }, + [setAutoSwitch] + ); + + return ( + + + Generations + + + + Auto-switch + + + + + ); + } +); +StagingAreaHeader.displayName = 'StagingAreaHeader'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx new file mode 100644 index 0000000000..6cbc10d334 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -0,0 +1,135 @@ +import type { + AdvancedSessionIdentifier, + SimpleSessionIdentifier, +} from 'features/controlLayers/store/canvasStagingAreaSlice'; +import type { ProgressImage } from 'features/nodes/types/common'; +import { atom, type WritableAtom } from 'nanostores'; +import type { PropsWithChildren } from 'react'; +import { createContext, memo, useContext, useEffect, useState } from 'react'; +import type { S } from 'services/api/types'; +import { assert } from 'tsafe'; + +export type ProgressData = { + sessionId: string; + progressEvent: S['InvocationProgressEvent'] | null; + progressImage: ProgressImage | null; +}; + +export const buildProgressDataAtom = () => atom>({}); + +export const useProgressData = ( + $progressData: WritableAtom>, + sessionId: string +): ProgressData => { + const [value, setValue] = useState(() => { + return $progressData.get()[sessionId] ?? { sessionId, progressEvent: null, progressImage: null }; + }); + useEffect(() => { + const unsub = $progressData.subscribe((data) => { + const progressData = data[sessionId]; + if (!progressData) { + return; + } + setValue(progressData); + }); + return () => { + unsub(); + }; + }, [$progressData, sessionId]); + + return value; +}; + +export const useHasProgressImage = ( + $progressData: WritableAtom>, + sessionId: string +): boolean => { + const [value, setValue] = useState(false); + useEffect(() => { + const unsub = $progressData.subscribe((data) => { + const progressData = data[sessionId]; + setValue(Boolean(progressData?.progressImage)); + }); + return () => { + unsub(); + }; + }, [$progressData, sessionId]); + + return value; +}; + +export const setProgress = ( + $progressData: WritableAtom>, + data: S['InvocationProgressEvent'] +) => { + const progressData = $progressData.get(); + const current = progressData[data.session_id]; + if (current) { + const next = { ...current }; + next.progressEvent = data; + if (data.image) { + next.progressImage = data.image; + } + $progressData.set({ + ...progressData, + [data.session_id]: next, + }); + } else { + $progressData.set({ + ...progressData, + [data.session_id]: { + sessionId: data.session_id, + progressEvent: data, + progressImage: data.image ?? null, + }, + }); + } +}; + +export const clearProgressEvent = ($progressData: WritableAtom>, sessionId: string) => { + const progressData = $progressData.get(); + const current = progressData[sessionId]; + if (!current) { + return; + } + const next = { ...current }; + next.progressEvent = null; + $progressData.set({ + ...progressData, + [sessionId]: next, + }); +}; + +export const clearProgressImage = ($progressData: WritableAtom>, sessionId: string) => { + const progressData = $progressData.get(); + const current = progressData[sessionId]; + if (!current) { + return; + } + const next = { ...current }; + next.progressImage = null; + $progressData.set({ + ...progressData, + [sessionId]: next, + }); +}; + +export type CanvasSessionContextValue = { + session: SimpleSessionIdentifier | AdvancedSessionIdentifier; + $progressData: WritableAtom>; +}; + +const CanvasSessionContext = createContext(null); + +export const CanvasSessionContextProvider = memo( + ({ value, children }: PropsWithChildren<{ value: CanvasSessionContextValue }>) => ( + {children} + ) +); +CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider'; + +export const useCanvasSessionContext = () => { + const ctx = useContext(CanvasSessionContext); + assert(ctx !== null, "'useCanvasSessionContext' must be used within a CanvasSessionContextProvider"); + return ctx; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts new file mode 100644 index 0000000000..6736e2c306 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts @@ -0,0 +1,51 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { isImageField } from 'features/nodes/types/common'; +import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; +import { round } from 'lodash-es'; +import { useMemo } from 'react'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import type { S } from 'services/api/types'; +import { objectEntries } from 'tsafe'; + +export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) => { + if (!data) { + return 'Generating'; + } + + let message = data.message; + if (data.percentage) { + message += ` (${round(data.percentage * 100)}%)`; + } + return message; +}; + +export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))'; + +export const getQueueItemElementId = (item_id: number) => `queue-item-status-card-${item_id}`; + +const getOutputImageName = (item: S['SessionQueueItem']) => { + const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => + isCanvasOutputNodeId(nodeId) + )?.[1][0]; + const output = nodeId ? item.session.results[nodeId] : undefined; + + if (!output) { + return null; + } + + for (const [_name, value] of objectEntries(output)) { + if (isImageField(value)) { + return value.image_name; + } + } + + return null; +}; + +export const useOutputImageDTO = (item: S['SessionQueueItem']) => { + const outputImageName = useMemo(() => getOutputImageName(item), [item]); + + const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); + + return imageDTO; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts new file mode 100644 index 0000000000..9e2ac2f24c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import type { S } from 'services/api/types'; + +export const useStagingAreaKeyboardNav = ( + items: S['SessionQueueItem'][], + selectedItemId: number | null, + onSelectItemId: (item_id: number) => void +) => { + const onNext = useCallback(() => { + if (selectedItemId === null) { + return; + } + const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); + const nextIndex = (currentIndex + 1) % items.length; + const nextItem = items[nextIndex]; + if (!nextItem) { + return; + } + onSelectItemId(nextItem.item_id); + }, [items, onSelectItemId, selectedItemId]); + const onPrev = useCallback(() => { + if (selectedItemId === null) { + return; + } + const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); + const prevIndex = (currentIndex - 1 + items.length) % items.length; + const prevItem = items[prevIndex]; + if (!prevItem) { + return; + } + onSelectItemId(prevItem.item_id); + }, [items, onSelectItemId, selectedItemId]); + + const onFirst = useCallback(() => { + const first = items.at(0); + if (!first) { + return; + } + onSelectItemId(first.item_id); + }, [items, onSelectItemId]); + const onLast = useCallback(() => { + const last = items.at(-1); + if (!last) { + return; + } + onSelectItemId(last.item_id); + }, [items, onSelectItemId]); + + useHotkeys('left', onPrev, { preventDefault: true }); + useHotkeys('right', onNext, { preventDefault: true }); + useHotkeys('meta+left', onFirst, { preventDefault: true }); + useHotkeys('meta+right', onLast, { preventDefault: true }); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index bf035be31c..4a403b6baf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -6,8 +6,18 @@ import { canvasReset } from 'features/controlLayers/store/actions'; import type { StagingAreaImage, StagingAreaProgressImage } from 'features/controlLayers/store/types'; import { selectCanvasQueueCounts } from 'services/api/endpoints/queue'; +export type SimpleSessionIdentifier = { + type: 'simple'; + id: string; +}; + +export type AdvancedSessionIdentifier = { + type: 'advanced'; + id: string; +}; + type CanvasStagingAreaState = { - session: { type: 'simple'; id: string } | { type: 'advanced'; id: string } | null; + session: SimpleSessionIdentifier | AdvancedSessionIdentifier | null; sessionType: 'simple' | 'advanced' | null; images: (StagingAreaImage | StagingAreaProgressImage)[]; selectedImageIndex: number; From 7ec511da014d4a6badb818ff7c2feef6b2f1e93a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:31:07 +1000 Subject: [PATCH 041/210] feat(app): do not prune queue on startup With the new canvas design, this will result in loss of staging area images. --- invokeai/app/services/session_queue/session_queue_sqlite.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 4ba16fb85a..0a0ef72456 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -45,10 +45,6 @@ class SqliteSessionQueue(SessionQueueBase): clear_result = self.clear(DEFAULT_QUEUE_ID) if clear_result.deleted > 0: self.__invoker.services.logger.info(f"Cleared all {clear_result.deleted} queue items") - else: - prune_result = self.prune(DEFAULT_QUEUE_ID) - if prune_result.deleted > 0: - self.__invoker.services.logger.info(f"Pruned {prune_result.deleted} finished queue items") def __init__(self, db: SqliteDatabase) -> None: super().__init__() From d2155e98efcbec441f7389efc2665a33b694d72d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:36:27 +1000 Subject: [PATCH 042/210] feat(ui): remove clear queue ui components --- .../queue/components/ClearQueueIconButton.tsx | 33 +------------------ .../components/QueueActionsMenuButton.tsx | 24 +------------- .../components/QueueTabQueueControls.tsx | 13 +++----- .../components/FloatingLeftPanelButtons.tsx | 23 ------------- 4 files changed, 6 insertions(+), 87 deletions(-) diff --git a/invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx b/invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx index 348dd38ada..9f7a4290fa 100644 --- a/invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx @@ -1,28 +1,17 @@ import { IconButton, useShiftModifier } from '@invoke-ai/ui-library'; import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiTrashSimpleBold, PiXBold, PiXCircle } from 'react-icons/pi'; - -import { useClearQueueDialog } from './ClearQueueConfirmationAlertDialog'; +import { PiXBold, PiXCircle } from 'react-icons/pi'; export const ClearQueueIconButton = memo(() => { - const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll'); const shift = useShiftModifier(); if (!shift) { - // Shift is not pressed - show cancel current return ; } - if (isCancelAndClearAllEnabled) { - // Shift is pressed and cancel and clear all is enabled - show cancel and clear all - return ; - } - - // Shift is pressed and cancel and clear all is disabled - show cancel all except current return ; }); @@ -48,26 +37,6 @@ const CancelCurrentIconButton = memo(() => { CancelCurrentIconButton.displayName = 'CancelCurrentIconButton'; -const CancelAndClearAllIconButton = memo(() => { - const { t } = useTranslation(); - const clearQueue = useClearQueueDialog(); - - return ( - } - colorScheme="error" - onClick={clearQueue.openDialog} - /> - ); -}); - -CancelAndClearAllIconButton.displayName = 'CancelAndClearAllIconButton'; - const CancelAllExceptCurrentIconButton = memo(() => { const { t } = useTranslation(); const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx index 42b8d704bd..be1ebeedc7 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx @@ -2,7 +2,6 @@ import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@in import { useAppDispatch } from 'app/store/storeHooks'; import { SessionMenuItems } from 'common/components/SessionMenuItems'; import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; -import { useClearQueueDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { QueueCountBadge } from 'features/queue/components/QueueCountBadge'; import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; import { usePauseProcessor } from 'features/queue/hooks/usePauseProcessor'; @@ -11,15 +10,7 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { - PiListBold, - PiPauseFill, - PiPlayFill, - PiQueueBold, - PiTrashSimpleBold, - PiXBold, - PiXCircle, -} from 'react-icons/pi'; +import { PiListBold, PiPauseFill, PiPlayFill, PiQueueBold, PiXBold, PiXCircle } from 'react-icons/pi'; export const QueueActionsMenuButton = memo(() => { const ref = useRef(null); @@ -27,10 +18,8 @@ export const QueueActionsMenuButton = memo(() => { const { t } = useTranslation(); const isPauseEnabled = useFeatureStatus('pauseQueue'); const isResumeEnabled = useFeatureStatus('resumeQueue'); - const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll'); const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); const cancelCurrent = useCancelCurrentQueueItem(); - const clearQueue = useClearQueueDialog(); const { resumeProcessor, isLoading: isLoadingResumeProcessor, @@ -72,17 +61,6 @@ export const QueueActionsMenuButton = memo(() => { > {t('queue.cancelAllExceptCurrentTooltip')} - {isCancelAndClearAllEnabled && ( - } - onClick={clearQueue.openDialog} - isLoading={clearQueue.isLoading} - isDisabled={clearQueue.isDisabled} - > - {t('queue.clearTooltip')} - - )} {isResumeEnabled && ( } diff --git a/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx b/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx index 7e679caf74..17c3ed104f 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx @@ -5,7 +5,6 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react'; import ClearModelCacheButton from './ClearModelCacheButton'; -import ClearQueueButton from './ClearQueueButton'; import PauseProcessorButton from './PauseProcessorButton'; import PruneQueueButton from './PruneQueueButton'; import ResumeProcessorButton from './ResumeProcessorButton'; @@ -13,23 +12,19 @@ import ResumeProcessorButton from './ResumeProcessorButton'; const QueueTabQueueControls = () => { const isPauseEnabled = useFeatureStatus('pauseQueue'); const isResumeEnabled = useFeatureStatus('resumeQueue'); - const isCancelAndClearAllEnabled = useFeatureStatus('cancelAndClearAll'); return ( - {isPauseEnabled || isResumeEnabled ? ( + {(isPauseEnabled || isResumeEnabled) && ( - {isResumeEnabled ? : <>} - {isPauseEnabled ? : <>} + {isResumeEnabled && } + {isPauseEnabled && } - ) : ( - <> )} - {isCancelAndClearAllEnabled && } - {!isCancelAndClearAllEnabled && } + diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx index bbd10d868c..f80a956319 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx @@ -4,7 +4,6 @@ import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser' import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; -import { useClearQueueDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip'; import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; import { useInvoke } from 'features/queue/hooks/useInvoke'; @@ -16,7 +15,6 @@ import { PiLightningFill, PiSlidersHorizontalBold, PiSparkleFill, - PiTrashSimpleBold, PiXBold, PiXCircle, } from 'react-icons/pi'; @@ -126,27 +124,6 @@ const CancelCurrentIconButton = memo(() => { CancelCurrentIconButton.displayName = 'CancelCurrentIconButton'; -const CancelAndClearAllIconButton = memo(() => { - const { t } = useTranslation(); - const clearQueue = useClearQueueDialog(); - - return ( - - } - colorScheme="error" - onClick={clearQueue.openDialog} - flexGrow={1} - /> - - ); -}); - -CancelAndClearAllIconButton.displayName = 'CancelAndClearAllIconButton'; - const CancelAllExceptCurrentIconButton = memo(() => { const { t } = useTranslation(); const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); From 5088e700ad17f2dba9da9a259c1a5af4c14843fa Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:51:08 +1000 Subject: [PATCH 043/210] fix(ui): cursor on staging area preview image --- .../components/SimpleSession/QueueItemPreviewFull.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx index cc5085ba2a..de0504909b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx @@ -17,7 +17,6 @@ type Props = { }; const sx = { - cursor: 'pointer', userSelect: 'none', pos: 'relative', alignItems: 'center', From b05de8634dc2f6dc15749b10c5fd731cb3602e11 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:16:39 +1000 Subject: [PATCH 044/210] perf(ui): queue actions menu is lazy --- .../src/features/queue/components/QueueActionsMenuButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx index be1ebeedc7..e9a81c4c19 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx @@ -36,7 +36,7 @@ export const QueueActionsMenuButton = memo(() => { return ( <> - + } /> From 002816653e53dd7ffbf86a6d242fb2e35ef4f30d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:17:33 +1000 Subject: [PATCH 045/210] feat(ui): modularize all staging area logic so it can be shared w/ canvas more easily --- .../SimpleSession/SimpleSession.tsx | 15 +-- .../components/SimpleSession/StagingArea.tsx | 123 +++--------------- .../SimpleSession/StagingAreaContent.tsx | 70 +++------- .../SimpleSession/StagingAreaHeader.tsx | 60 ++++----- .../SimpleSession/StagingAreaItemsList.tsx | 31 +++++ .../SimpleSession/StagingAreaNoItems.tsx | 13 ++ .../SimpleSession/StagingAreaSelectedItem.tsx | 21 +++ .../components/SimpleSession/context.tsx | 113 +++++++++++++++- .../SimpleSession/use-progress-events.ts | 48 +++++++ .../SimpleSession/use-staging-keyboard-nav.ts | 31 +++-- 10 files changed, 303 insertions(+), 222 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx index 86d14450d2..2b543c6361 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx @@ -1,20 +1,11 @@ -import type { CanvasSessionContextValue } from 'features/controlLayers/components/SimpleSession/context'; -import { - buildProgressDataAtom, - CanvasSessionContextProvider, -} from 'features/controlLayers/components/SimpleSession/context'; +import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context'; import { StagingArea } from 'features/controlLayers/components/SimpleSession/StagingArea'; import type { SimpleSessionIdentifier } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { memo, useMemo } from 'react'; +import { memo } from 'react'; export const SimpleSession = memo(({ session }: { session: SimpleSessionIdentifier }) => { - const ctx = useMemo( - () => ({ session, $progressData: buildProgressDataAtom() }) satisfies CanvasSessionContextValue, - [session] - ); - return ( - + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx index 8eef21c9fd..c4dacd07d1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx @@ -1,129 +1,36 @@ /* eslint-disable i18next/no-literal-string */ -import { Divider, Flex, Text } from '@invoke-ai/ui-library'; +import { Divider, Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { EMPTY_ARRAY } from 'app/store/constants'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { StagingAreaContent } from 'features/controlLayers/components/SimpleSession/StagingAreaContent'; import { StagingAreaHeader } from 'features/controlLayers/components/SimpleSession/StagingAreaHeader'; +import { StagingAreaNoItems } from 'features/controlLayers/components/SimpleSession/StagingAreaNoItems'; +import { useProgressEvents } from 'features/controlLayers/components/SimpleSession/use-progress-events'; import { useStagingAreaKeyboardNav } from 'features/controlLayers/components/SimpleSession/use-staging-keyboard-nav'; -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useListAllQueueItemsQuery } from 'services/api/endpoints/queue'; -import type { S } from 'services/api/types'; -import { $socket, setProgress } from 'services/events/stores'; - -const LIST_ALL_OPTIONS = { - selectFromResult: ({ data }) => { - if (!data) { - return { items: EMPTY_ARRAY }; - } - return { items: data.filter(({ status }) => status !== 'canceled') }; - }, -} satisfies Parameters[1]; +import { memo, useEffect } from 'react'; export const StagingArea = memo(() => { const ctx = useCanvasSessionContext(); - const [selectedItemId, setSelectedItemId] = useState(null); - const [autoSwitch, setAutoSwitch] = useState(true); - const { items } = useListAllQueueItemsQuery({ destination: ctx.session.id }, LIST_ALL_OPTIONS); - const selectedItem = useMemo(() => { - if (items.length === 0) { - return null; - } - if (selectedItemId === null) { - return null; - } - return items.find(({ item_id }) => item_id === selectedItemId) ?? null; - }, [items, selectedItemId]); - const selectedItemIndex = useMemo(() => { - if (items.length === 0) { - return null; - } - if (selectedItemId === null) { - return null; - } - return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null; - }, [items, selectedItemId]); - - const onSelectItemId = useCallback((item_id: number | null) => { - setSelectedItemId(item_id); - if (item_id !== null) { - document.getElementById(getQueueItemElementId(item_id))?.scrollIntoView(); - } - }, []); - - useStagingAreaKeyboardNav(items, selectedItemId, onSelectItemId); + const hasItems = useStore(ctx.$hasItems); + useProgressEvents(); + useStagingAreaKeyboardNav(); useEffect(() => { - if (items.length === 0) { - onSelectItemId(null); - return; - } - if (selectedItemId === null && items.length > 0) { - onSelectItemId(items[0]?.item_id ?? null); - return; - } - }, [items, onSelectItemId, selectedItem, selectedItemId]); - - const socket = useStore($socket); - useEffect(() => { - if (!socket) { - return; - } - - const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { - if (data.destination !== ctx.session.id) { - return; + return ctx.$selectedItemId.listen((id) => { + if (id !== null) { + document.getElementById(getQueueItemElementId(id))?.scrollIntoView(); } - if (data.status === 'in_progress' && autoSwitch) { - onSelectItemId(data.item_id); - } - }; - - socket.on('queue_item_status_changed', onQueueItemStatusChanged); - - return () => { - socket.off('queue_item_status_changed', onQueueItemStatusChanged); - }; - }, [autoSwitch, ctx.$progressData, ctx.session.id, onSelectItemId, socket]); - - useEffect(() => { - if (!socket) { - return; - } - const onProgress = (data: S['InvocationProgressEvent']) => { - if (data.destination !== ctx.session.id) { - return; - } - setProgress(ctx.$progressData, data); - }; - socket.on('invocation_progress', onProgress); - - return () => { - socket.off('invocation_progress', onProgress); - }; - }, [ctx.$progressData, ctx.session.id, socket]); + }); + }, [ctx.$selectedItemId]); return ( - + - {items.length > 0 && ( - - )} - {items.length === 0 && ( - - No generations - - )} + {hasItems && } + {!hasItems && } ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx index ce1e29b3b2..76cafe25ea 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx @@ -1,58 +1,20 @@ /* eslint-disable i18next/no-literal-string */ -import { Divider, Flex, Text } from '@invoke-ai/ui-library'; -import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; -import { QueueItemPreviewFull } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewFull'; -import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini'; +import { Divider, Flex } from '@invoke-ai/ui-library'; +import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; +import { StagingAreaSelectedItem } from 'features/controlLayers/components/SimpleSession/StagingAreaSelectedItem'; import { memo } from 'react'; -import type { S } from 'services/api/types'; -export const StagingAreaContent = memo( - ({ - items, - selectedItem, - selectedItemId, - selectedItemIndex, - onChangeAutoSwitch, - onSelectItemId, - }: { - items: S['SessionQueueItem'][]; - selectedItem: S['SessionQueueItem'] | null; - selectedItemId: number | null; - selectedItemIndex: number | null; - onChangeAutoSwitch: (autoSwitch: boolean) => void; - onSelectItemId: (itemId: number) => void; - }) => { - return ( - <> - - {selectedItem && selectedItemIndex !== null && ( - - )} - {!selectedItem && No generation selected} - - - - - - {items.map((item, i) => ( - - ))} - - - - - ); - } -); +export const StagingAreaContent = memo(() => { + return ( + <> + + + + + + + + + ); +}); StagingAreaContent.displayName = 'StagingAreaContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx index f9e04c1ef4..7e8963c298 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -1,40 +1,42 @@ /* eslint-disable i18next/no-literal-string */ import { Button, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppDispatch } from 'app/store/storeHooks'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ChangeEvent } from 'react'; import { memo, useCallback } from 'react'; -export const StagingAreaHeader = memo( - ({ autoSwitch, setAutoSwitch }: { autoSwitch: boolean; setAutoSwitch: (autoSwitch: boolean) => void }) => { - const dispatch = useAppDispatch(); +export const StagingAreaHeader = memo(() => { + const ctx = useCanvasSessionContext(); + const autoSwitch = useStore(ctx.$autoSwitch); + const dispatch = useAppDispatch(); - const startOver = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); - }, [dispatch]); + const startOver = useCallback(() => { + dispatch(canvasSessionStarted({ sessionType: 'simple' })); + }, [dispatch]); - const onChangeAutoSwitch = useCallback( - (e: ChangeEvent) => { - setAutoSwitch(e.target.checked); - }, - [setAutoSwitch] - ); + const onChangeAutoSwitch = useCallback( + (e: ChangeEvent) => { + ctx.$autoSwitch.set(e.target.checked); + }, + [ctx.$autoSwitch] + ); - return ( - - - Generations - - - - Auto-switch - - - - - ); - } -); + return ( + + + Generations + + + + Auto-switch + + + + + ); +}); StagingAreaHeader.displayName = 'StagingAreaHeader'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx new file mode 100644 index 0000000000..1146119933 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx @@ -0,0 +1,31 @@ +/* eslint-disable i18next/no-literal-string */ +import { Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini'; +import { memo } from 'react'; + +export const StagingAreaItemsList = memo(() => { + const ctx = useCanvasSessionContext(); + const items = useStore(ctx.$items); + const selectedItemId = useStore(ctx.$selectedItemId); + + return ( + + + {items.map((item, i) => ( + + ))} + + + ); +}); +StagingAreaItemsList.displayName = 'StagingAreaItemsList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx new file mode 100644 index 0000000000..7c85c52c7a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx @@ -0,0 +1,13 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Flex, Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; + +export const StagingAreaNoItems = memo(() => { + return ( + + No generations + + ); +}); +StagingAreaNoItems.displayName = 'StagingAreaNoItems'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx new file mode 100644 index 0000000000..67f8143529 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx @@ -0,0 +1,21 @@ +/* eslint-disable i18next/no-literal-string */ +import { Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { QueueItemPreviewFull } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewFull'; +import { memo } from 'react'; + +export const StagingAreaSelectedItem = memo(() => { + const ctx = useCanvasSessionContext(); + const selectedItem = useStore(ctx.$selectedItem); + const selectedItemIndex = useStore(ctx.$selectedItemIndex); + + if (selectedItem && selectedItemIndex !== null) { + return ( + + ); + } + + return No generation selected; +}); +StagingAreaSelectedItem.displayName = 'StagingAreaSelectedItem'; 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 6cbc10d334..b2b85d21a0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -1,11 +1,16 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import { useAppStore } from 'app/store/nanostores/store'; import type { AdvancedSessionIdentifier, SimpleSessionIdentifier, } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ProgressImage } from 'features/nodes/types/common'; -import { atom, type WritableAtom } from 'nanostores'; +import type { Atom, WritableAtom } from 'nanostores'; +import { atom, computed, effect } from 'nanostores'; import type { PropsWithChildren } from 'react'; -import { createContext, memo, useContext, useEffect, useState } from 'react'; +import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; +import { queueApi } from 'services/api/endpoints/queue'; import type { S } from 'services/api/types'; import { assert } from 'tsafe'; @@ -116,15 +121,113 @@ export const clearProgressImage = ($progressData: WritableAtom; + $hasItems: Atom; $progressData: WritableAtom>; + $selectedItemId: WritableAtom; + $selectedItem: Atom; + $selectedItemIndex: Atom; + $autoSwitch: WritableAtom; }; const CanvasSessionContext = createContext(null); export const CanvasSessionContextProvider = memo( - ({ value, children }: PropsWithChildren<{ value: CanvasSessionContextValue }>) => ( - {children} - ) + ({ session, children }: PropsWithChildren<{ session: SimpleSessionIdentifier | AdvancedSessionIdentifier }>) => { + const store = useAppStore(); + const [$items] = useState(() => atom([])); + const [$hasItems] = useState(() => computed([$items], (items) => items.length > 0)); + const [$autoSwitch] = useState(() => atom(true)); + const [$selectedItemId] = useState(() => atom(null)); + const [$progressData] = useState(() => atom>({})); + 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; + }) + ); + const [$selectedItemIndex] = useState(() => + computed([$items, $selectedItemId], (items, selectedItemId) => { + if (items.length === 0) { + return null; + } + if (selectedItemId === null) { + return null; + } + return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null; + }) + ); + + const selectQueueItems = useMemo( + () => + createSelector( + queueApi.endpoints.listAllQueueItems.select({ destination: session.id }), + ({ data }) => data?.filter((item) => item.status !== 'canceled') ?? EMPTY_ARRAY + ), + [session.id] + ); + + useEffect(() => { + $items.set(selectQueueItems(store.getState())); + + const unsubReduxSyncToItemsAtom = store.subscribe(() => { + const prevItems = $items.get(); + const items = selectQueueItems(store.getState()); + if (items !== prevItems) { + $items.set(items); + } + }); + + const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => { + if (items.length === 0) { + $selectedItemId.set(null); + return; + } + if (selectedItemId === null && items.length > 0) { + $selectedItemId.set(items[0]?.item_id ?? null); + return; + } + if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) { + $selectedItemId.set(null); + return; + } + }); + + const { unsubscribe: unsubQueueItemsQuery } = store.dispatch( + queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id }) + ); + + return () => { + unsubQueueItemsQuery(); + unsubReduxSyncToItemsAtom(); + unsubEnsureSelectedItemIdExists(); + $items.set([]); + $progressData.set({}); + $selectedItemId.set(null); + }; + }, [$items, $progressData, $selectedItemId, selectQueueItems, session.id, store]); + + const value = useMemo( + () => ({ + session, + $items, + $hasItems, + $progressData, + $selectedItemId, + $autoSwitch, + $selectedItem, + $selectedItemIndex, + }), + [$autoSwitch, $hasItems, $items, $progressData, $selectedItem, $selectedItemId, $selectedItemIndex, session] + ); + + return {children}; + } ); CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts new file mode 100644 index 0000000000..737ca3fdd0 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts @@ -0,0 +1,48 @@ +import { useStore } from '@nanostores/react'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useEffect } from 'react'; +import type { S } from 'services/api/types'; +import { $socket, setProgress } from 'services/events/stores'; + +export const useProgressEvents = () => { + const ctx = useCanvasSessionContext(); + const socket = useStore($socket); + useEffect(() => { + if (!socket) { + return; + } + + const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { + if (data.destination !== ctx.session.id) { + return; + } + if (data.status === 'completed' && ctx.$autoSwitch.get()) { + ctx.$selectedItemId.set(data.item_id); + } + }; + + socket.on('queue_item_status_changed', onQueueItemStatusChanged); + + return () => { + socket.off('queue_item_status_changed', onQueueItemStatusChanged); + }; + }, [ctx.$autoSwitch, ctx.$progressData, ctx.$selectedItemId, ctx.session.id, socket]); + + useEffect(() => { + if (!socket) { + return; + } + const onProgress = (data: S['InvocationProgressEvent']) => { + if (data.destination !== ctx.session.id) { + return; + } + // TODO: clear progress when done w/ it memory leak + setProgress(ctx.$progressData, data); + }; + socket.on('invocation_progress', onProgress); + + return () => { + socket.off('invocation_progress', onProgress); + }; + }, [ctx.$progressData, ctx.session.id, socket]); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts index 9e2ac2f24c..6a11b5fa99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts @@ -1,51 +1,54 @@ +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import type { S } from 'services/api/types'; -export const useStagingAreaKeyboardNav = ( - items: S['SessionQueueItem'][], - selectedItemId: number | null, - onSelectItemId: (item_id: number) => void -) => { +export const useStagingAreaKeyboardNav = () => { + const ctx = useCanvasSessionContext(); const onNext = useCallback(() => { + const selectedItemId = ctx.$selectedItemId.get(); if (selectedItemId === null) { return; } + const items = ctx.$items.get(); const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); const nextIndex = (currentIndex + 1) % items.length; const nextItem = items[nextIndex]; if (!nextItem) { return; } - onSelectItemId(nextItem.item_id); - }, [items, onSelectItemId, selectedItemId]); + ctx.$selectedItemId.set(nextItem.item_id); + }, [ctx.$items, ctx.$selectedItemId]); const onPrev = useCallback(() => { + const selectedItemId = ctx.$selectedItemId.get(); if (selectedItemId === null) { return; } + const items = ctx.$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; } - onSelectItemId(prevItem.item_id); - }, [items, onSelectItemId, selectedItemId]); + ctx.$selectedItemId.set(prevItem.item_id); + }, [ctx.$items, ctx.$selectedItemId]); const onFirst = useCallback(() => { + const items = ctx.$items.get(); const first = items.at(0); if (!first) { return; } - onSelectItemId(first.item_id); - }, [items, onSelectItemId]); + ctx.$selectedItemId.set(first.item_id); + }, [ctx.$items, ctx.$selectedItemId]); const onLast = useCallback(() => { + const items = ctx.$items.get(); const last = items.at(-1); if (!last) { return; } - onSelectItemId(last.item_id); - }, [items, onSelectItemId]); + ctx.$selectedItemId.set(last.item_id); + }, [ctx.$items, ctx.$selectedItemId]); useHotkeys('left', onPrev, { preventDefault: true }); useHotkeys('right', onNext, { preventDefault: true }); From 628367b97b300e7da63040886ab13ef0c94f3010 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:47:29 +1000 Subject: [PATCH 046/210] feat(ui): move socket events handling into ctx component --- .../components/SimpleSession/StagingArea.tsx | 2 - .../components/SimpleSession/context.tsx | 128 ++++++++++++++++-- .../SimpleSession/use-progress-events.ts | 48 ------- 3 files changed, 118 insertions(+), 60 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx index c4dacd07d1..d00f6a4ec8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx @@ -7,14 +7,12 @@ import { getQueueItemElementId } from 'features/controlLayers/components/SimpleS import { StagingAreaContent } from 'features/controlLayers/components/SimpleSession/StagingAreaContent'; import { StagingAreaHeader } from 'features/controlLayers/components/SimpleSession/StagingAreaHeader'; import { StagingAreaNoItems } from 'features/controlLayers/components/SimpleSession/StagingAreaNoItems'; -import { useProgressEvents } from 'features/controlLayers/components/SimpleSession/use-progress-events'; import { useStagingAreaKeyboardNav } from 'features/controlLayers/components/SimpleSession/use-staging-keyboard-nav'; import { memo, useEffect } from 'react'; export const StagingArea = memo(() => { const ctx = useCanvasSessionContext(); const hasItems = useStore(ctx.$hasItems); - useProgressEvents(); useStagingAreaKeyboardNav(); useEffect(() => { 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 b2b85d21a0..64eae0b8f4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; @@ -12,6 +13,7 @@ import type { PropsWithChildren } from 'react'; import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; import { queueApi } from 'services/api/endpoints/queue'; import type { S } from 'services/api/types'; +import { $socket } from 'services/events/stores'; import { assert } from 'tsafe'; export type ProgressData = { @@ -134,13 +136,48 @@ const CanvasSessionContext = createContext(nul export const CanvasSessionContextProvider = memo( ({ session, children }: PropsWithChildren<{ session: SimpleSessionIdentifier | AdvancedSessionIdentifier }>) => { + /** + * 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 [$items] = useState(() => atom([])); - const [$hasItems] = useState(() => computed([$items], (items) => items.length > 0)); - const [$autoSwitch] = useState(() => atom(true)); - const [$selectedItemId] = useState(() => atom(null)); - const [$progressData] = useState(() => atom>({})); - const [$selectedItem] = useState(() => + + const socket = useStore($socket); + + /** + * Manually-synced atom containing the queue items for the current session. + */ + const $items = useState(() => atom([]))[0]; + + /** + * Whether auto-switch is enabled. + */ + const $autoSwitch = useState(() => atom(true))[0]; + + /** + * An ephemeral store of progress events and images for all items in the current session. + */ + const $progressData = useState(() => atom>({}))[0]; + + /** + * The currently selected queue item's ID, or null if one is not selected. + */ + const $selectedItemId = useState(() => atom(null))[0]; + + /** + * Whether there are any items. Computed from the queue items array. + */ + const $hasItems = useState(() => computed([$items], (items) => items.length > 0))[0]; + + /** + * The currently selected queue item, or null if one is not selected. + */ + const $selectedItem = useState(() => computed([$items, $selectedItemId], (items, selectedItemId) => { if (items.length === 0) { return null; @@ -150,8 +187,12 @@ export const CanvasSessionContextProvider = memo( } return items.find(({ item_id }) => item_id === selectedItemId) ?? null; }) - ); - const [$selectedItemIndex] = useState(() => + )[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; @@ -161,20 +202,59 @@ export const CanvasSessionContextProvider = memo( } return items.findIndex(({ item_id }) => item_id === selectedItemId) ?? null; }) - ); + )[0]; + /** + * A redux selector to select all queue items from the RTK Query cache. It's important that this returns stable + * references if possible to reduce re-renders. All derivations of the queue items (e.g. filtering out canceled + * items) should be done in a nanostores computed. + */ const selectQueueItems = useMemo( () => createSelector( queueApi.endpoints.listAllQueueItems.select({ destination: session.id }), - ({ data }) => data?.filter((item) => item.status !== 'canceled') ?? EMPTY_ARRAY + ({ data }) => data ?? EMPTY_ARRAY ), [session.id] ); + // Set up socket listeners useEffect(() => { + if (!socket) { + return; + } + + const onProgress = (data: S['InvocationProgressEvent']) => { + if (data.destination !== session.id) { + return; + } + setProgress($progressData, data); + }; + + const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { + if (data.destination !== session.id) { + return; + } + if (data.status === 'completed' && $autoSwitch.get()) { + $selectedItemId.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); + }; + }, [$autoSwitch, $progressData, $selectedItemId, session.id, socket]); + + // Set up state subscriptions and effects + useEffect(() => { + // Seed the $items atom with the initial query cache state $items.set(selectQueueItems(store.getState())); + // Manually keep the $items atom in sync as the query cache is updated const unsubReduxSyncToItemsAtom = store.subscribe(() => { const prevItems = $items.get(); const items = selectQueueItems(store.getState()); @@ -183,29 +263,57 @@ export const CanvasSessionContextProvider = memo( } }); + // Handle cases that could result in a nonexistent queue item being selected. const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => { + // If there are no items, cannot have a selected item. if (items.length === 0) { $selectedItemId.set(null); return; } + // If there is no selected item but there are items, select the first one. if (selectedItemId === null && items.length > 0) { $selectedItemId.set(items[0]?.item_id ?? null); return; } + // 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. if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) { $selectedItemId.set(null); return; } }); + // Clean up the progress data when a queue item is discarded. + const unsubCleanUpProgressData = effect([$items, $progressData], (items, progressData) => { + const toDelete: string[] = []; + for (const datum of Object.values(progressData)) { + if (items.findIndex(({ session_id }) => session_id === datum.sessionId) === -1) { + toDelete.push(datum.sessionId); + } + } + if (toDelete.length === 0) { + return; + } + const newProgressData = { ...progressData }; + for (const sessionId of toDelete) { + delete newProgressData[sessionId]; + } + // This will re-trigger the effect - maybe this could just be a listener on $items? Brain hurt + $progressData.set(newProgressData); + }); + + // Create an RTK Query subscription. Without this, the query cache selector will never return anything bc RTK + // doesn't know we care about it. const { unsubscribe: unsubQueueItemsQuery } = store.dispatch( queueApi.endpoints.listAllQueueItems.initiate({ destination: session.id }) ); + // Clean up all subscriptions and top-level (i.e. non-computed/derived state) return () => { unsubQueueItemsQuery(); unsubReduxSyncToItemsAtom(); unsubEnsureSelectedItemIdExists(); + unsubCleanUpProgressData(); $items.set([]); $progressData.set({}); $selectedItemId.set(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts deleted file mode 100644 index 737ca3fdd0..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-progress-events.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { useEffect } from 'react'; -import type { S } from 'services/api/types'; -import { $socket, setProgress } from 'services/events/stores'; - -export const useProgressEvents = () => { - const ctx = useCanvasSessionContext(); - const socket = useStore($socket); - useEffect(() => { - if (!socket) { - return; - } - - const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { - if (data.destination !== ctx.session.id) { - return; - } - if (data.status === 'completed' && ctx.$autoSwitch.get()) { - ctx.$selectedItemId.set(data.item_id); - } - }; - - socket.on('queue_item_status_changed', onQueueItemStatusChanged); - - return () => { - socket.off('queue_item_status_changed', onQueueItemStatusChanged); - }; - }, [ctx.$autoSwitch, ctx.$progressData, ctx.$selectedItemId, ctx.session.id, socket]); - - useEffect(() => { - if (!socket) { - return; - } - const onProgress = (data: S['InvocationProgressEvent']) => { - if (data.destination !== ctx.session.id) { - return; - } - // TODO: clear progress when done w/ it memory leak - setProgress(ctx.$progressData, data); - }; - socket.on('invocation_progress', onProgress); - - return () => { - socket.off('invocation_progress', onProgress); - }; - }, [ctx.$progressData, ctx.session.id, socket]); -}; From c8df7cd2c00553c775362993b26db298edf957da Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:28:53 +1000 Subject: [PATCH 047/210] feat(ui): prevent flicker of image action buttons --- .../components/SimpleSession/QueueItemPreviewFull.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx index de0504909b..0c867db89a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx @@ -39,7 +39,7 @@ export const QueueItemPreviewFull = memo(({ item, number }: Props) => { {imageDTO && } {!imageLoaded && } - {imageDTO && imageLoaded && } + {imageDTO && } Date: Thu, 5 Jun 2025 11:54:06 +1000 Subject: [PATCH 048/210] feat: canvas flow rework (wip) --- .../QueueItemCircularProgress.tsx | 48 ++++---- .../SimpleSession/QueueItemPreviewFull.tsx | 18 +-- .../SimpleSession/QueueItemPreviewMini.tsx | 29 ++--- .../SimpleSession/QueueItemProgressImage.tsx | 11 +- .../QueueItemProgressMessage.tsx | 35 +++--- .../SimpleSession/QueueItemStatusLabel.tsx | 66 +++++------ .../SimpleSession/StagingAreaItemsList.tsx | 2 - .../components/SimpleSession/context.tsx | 104 +++++++++++------- .../components/SimpleSession/shared.ts | 2 +- .../web/src/services/events/stores.ts | 99 ----------------- 10 files changed, 156 insertions(+), 258 deletions(-) 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 ebf32f8082..80da7d250d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemCircularProgress.tsx @@ -1,6 +1,6 @@ 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 { 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'; @@ -15,32 +15,28 @@ const circleStyles: SystemStyleObject = { right: 2, }; -export const QueueItemCircularProgress = memo( - ({ - session_id, - status, - ...rest - }: { session_id: string; status: S['SessionQueueItem']['status'] } & CircularProgressProps) => { - const { $progressData } = useCanvasSessionContext(); - const { progressEvent } = useProgressData($progressData, session_id); +type Props = { itemId: number; status: S['SessionQueueItem']['status'] } & CircularProgressProps; - if (status !== 'in_progress') { - return null; - } +export const QueueItemCircularProgress = memo(({ itemId, status, ...rest }: Props) => { + const { $progressData } = useCanvasSessionContext(); + const { progressEvent } = useProgressData($progressData, itemId); - return ( - - - - ); + if (status !== 'in_progress') { + return null; } -); + + return ( + + + + ); +}); QueueItemCircularProgress.displayName = 'QueueItemCircularProgress'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx index 0c867db89a..11a3904bd9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx @@ -38,23 +38,11 @@ export const QueueItemPreviewFull = memo(({ item, number }: Props) => { {imageDTO && } - {!imageLoaded && } + {!imageLoaded && } {imageDTO && } - - + + ); }); 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 ffecb9c9b8..2578731bfa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -1,5 +1,6 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; +import { useCanvasSessionContext } 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'; @@ -34,25 +35,25 @@ type Props = { item: S['SessionQueueItem']; number: number; isSelected: boolean; - onSelectItemId: (item_id: number) => void; - onChangeAutoSwitch: (autoSwitch: boolean) => void; }; -export const QueueItemPreviewMini = memo(({ item, isSelected, number, onSelectItemId, onChangeAutoSwitch }: Props) => { +export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) => { + const ctx = useCanvasSessionContext(); const [imageLoaded, setImageLoaded] = useState(false); const imageDTO = useOutputImageDTO(item); const onClick = useCallback(() => { - onSelectItemId(item.item_id); - }, [item.item_id, onSelectItemId]); + ctx.$selectedItemId.set(item.item_id); + }, [ctx.$selectedItemId, item.item_id]); const onDoubleClick = useCallback(() => { - onChangeAutoSwitch(item.status === 'in_progress'); - }, [item.status, onChangeAutoSwitch]); + ctx.$autoSwitch.set(item.status === 'in_progress'); + }, [ctx.$autoSwitch, item.status]); const onLoad = useCallback(() => { setImageLoaded(true); - }, []); + ctx.$lastLoadedItemId.set(item.item_id); + }, [ctx.$lastLoadedItemId, item.item_id]); return ( - {imageDTO && } - {!imageLoaded && } + {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 55e8d836b7..2ea3dd827e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -1,12 +1,13 @@ import type { ImageProps } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { memo } from 'react'; -import { useProgressData } from 'services/events/stores'; -export const QueueItemProgressImage = memo(({ session_id, ...rest }: { session_id: string } & ImageProps) => { - const { $progressData } = useCanvasSessionContext(); - const { progressImage } = useProgressData($progressData, session_id); +type Props = { itemId: number } & ImageProps; + +export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => { + const ctx = useCanvasSessionContext(); + const { progressImage } = useProgressData(ctx.$progressData, itemId); if (!progressImage) { return null; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx index d287811fb3..fc60a93acf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx @@ -1,34 +1,33 @@ /* eslint-disable i18next/no-literal-string */ import type { TextProps } from '@invoke-ai/ui-library'; import { Text } from '@invoke-ai/ui-library'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { DROP_SHADOW, getProgressMessage } from 'features/controlLayers/components/SimpleSession/shared'; import { memo } from 'react'; import type { S } from 'services/api/types'; -import { useProgressData } from 'services/events/stores'; -export const QueueItemProgressMessage = memo( - ({ session_id, status, ...rest }: { session_id: string; status: S['SessionQueueItem']['status'] } & TextProps) => { - const { $progressData } = useCanvasSessionContext(); - const { progressEvent } = useProgressData($progressData, session_id); +type Props = { itemId: number; status: S['SessionQueueItem']['status'] } & TextProps; - if (status === 'completed' || status === 'failed' || status === 'canceled') { - return null; - } +export const QueueItemProgressMessage = memo(({ itemId, status, ...rest }: Props) => { + const ctx = useCanvasSessionContext(); + const { progressEvent } = useProgressData(ctx.$progressData, itemId); - if (status === 'pending') { - return ( - - Waiting to start... - - ); - } + if (status === 'completed' || status === 'failed' || status === 'canceled') { + return null; + } + if (status === 'pending') { return ( - {getProgressMessage(progressEvent)} + Waiting to start... ); } -); + + return ( + + {getProgressMessage(progressEvent)} + + ); +}); QueueItemProgressMessage.displayName = 'QueueItemProgressMessage'; 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 5924b63981..56cb4c92c0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx @@ -4,39 +4,39 @@ import { Text } from '@invoke-ai/ui-library'; import { memo } from 'react'; import type { S } from 'services/api/types'; -export const QueueItemStatusLabel = memo( - ({ status, ...rest }: { status: S['SessionQueueItem']['status'] } & TextProps) => { - if (status === 'pending') { - return ( - - Pending - - ); - } - if (status === 'canceled') { - return ( - - Canceled - - ); - } - if (status === 'failed') { - return ( - - Failed - - ); - } +type Props = { status: S['SessionQueueItem']['status'] } & TextProps; - if (status === 'in_progress') { - return ( - - In Progress - - ); - } - - return null; +export const QueueItemStatusLabel = memo(({ status, ...rest }: Props) => { + if (status === 'pending') { + return ( + + Pending + + ); } -); + if (status === 'canceled') { + return ( + + Canceled + + ); + } + if (status === 'failed') { + return ( + + Failed + + ); + } + + if (status === 'in_progress') { + return ( + + In Progress + + ); + } + + return null; +}); QueueItemStatusLabel.displayName = 'QueueItemStatusLabel'; 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 1146119933..d679835707 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx @@ -20,8 +20,6 @@ export const StagingAreaItemsList = memo(() => { item={item} number={i + 1} isSelected={selectedItemId === item.item_id} - onSelectItemId={ctx.$selectedItemId.set} - onChangeAutoSwitch={ctx.$autoSwitch.set} /> ))} 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 64eae0b8f4..c278e38c84 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -17,23 +17,21 @@ import { $socket } from 'services/events/stores'; import { assert } from 'tsafe'; export type ProgressData = { - sessionId: string; + itemId: number; progressEvent: S['InvocationProgressEvent'] | null; progressImage: ProgressImage | null; }; -export const buildProgressDataAtom = () => atom>({}); - export const useProgressData = ( - $progressData: WritableAtom>, - sessionId: string + $progressData: WritableAtom>, + itemId: number ): ProgressData => { const [value, setValue] = useState(() => { - return $progressData.get()[sessionId] ?? { sessionId, progressEvent: null, progressImage: null }; + return $progressData.get()[itemId] ?? { itemId, progressEvent: null, progressImage: null }; }); useEffect(() => { const unsub = $progressData.subscribe((data) => { - const progressData = data[sessionId]; + const progressData = data[itemId]; if (!progressData) { return; } @@ -42,35 +40,35 @@ export const useProgressData = ( return () => { unsub(); }; - }, [$progressData, sessionId]); + }, [$progressData, itemId]); return value; }; export const useHasProgressImage = ( - $progressData: WritableAtom>, - sessionId: string + $progressData: WritableAtom>, + itemId: number ): boolean => { const [value, setValue] = useState(false); useEffect(() => { const unsub = $progressData.subscribe((data) => { - const progressData = data[sessionId]; + const progressData = data[itemId]; setValue(Boolean(progressData?.progressImage)); }); return () => { unsub(); }; - }, [$progressData, sessionId]); + }, [$progressData, itemId]); return value; }; export const setProgress = ( - $progressData: WritableAtom>, + $progressData: WritableAtom>, data: S['InvocationProgressEvent'] ) => { const progressData = $progressData.get(); - const current = progressData[data.session_id]; + const current = progressData[data.item_id]; if (current) { const next = { ...current }; next.progressEvent = data; @@ -79,13 +77,13 @@ export const setProgress = ( } $progressData.set({ ...progressData, - [data.session_id]: next, + [data.item_id]: next, }); } else { $progressData.set({ ...progressData, - [data.session_id]: { - sessionId: data.session_id, + [data.item_id]: { + itemId: data.item_id, progressEvent: data, progressImage: data.image ?? null, }, @@ -93,9 +91,9 @@ export const setProgress = ( } }; -export const clearProgressEvent = ($progressData: WritableAtom>, sessionId: string) => { +export const clearProgressEvent = ($progressData: WritableAtom>, itemId: number) => { const progressData = $progressData.get(); - const current = progressData[sessionId]; + const current = progressData[itemId]; if (!current) { return; } @@ -103,13 +101,13 @@ export const clearProgressEvent = ($progressData: WritableAtom>, sessionId: string) => { +export const clearProgressImage = ($progressData: WritableAtom>, itemId: number) => { const progressData = $progressData.get(); - const current = progressData[sessionId]; + const current = progressData[itemId]; if (!current) { return; } @@ -117,7 +115,7 @@ export const clearProgressImage = ($progressData: WritableAtom; $selectedItemIndex: Atom; $autoSwitch: WritableAtom; + $lastLoadedItemId: WritableAtom; }; const CanvasSessionContext = createContext(null); @@ -159,10 +158,16 @@ export const CanvasSessionContextProvider = memo( */ const $autoSwitch = useState(() => atom(true))[0]; + /** + * An internal flag used to work around race conditions with auto-switch switching to queue items before their + * output images have fully loaded. + */ + const $lastLoadedItemId = useState(() => atom(null))[0]; + /** * An ephemeral store of progress events and images for all items in the current session. */ - const $progressData = useState(() => atom>({}))[0]; + const $progressData = useState(() => atom>({}))[0]; /** * The currently selected queue item's ID, or null if one is not selected. @@ -231,21 +236,10 @@ export const CanvasSessionContextProvider = memo( setProgress($progressData, data); }; - const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { - if (data.destination !== session.id) { - return; - } - if (data.status === 'completed' && $autoSwitch.get()) { - $selectedItemId.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); }; }, [$autoSwitch, $progressData, $selectedItemId, session.id, socket]); @@ -285,23 +279,37 @@ export const CanvasSessionContextProvider = memo( // Clean up the progress data when a queue item is discarded. const unsubCleanUpProgressData = effect([$items, $progressData], (items, progressData) => { - const toDelete: string[] = []; + const toDelete: number[] = []; for (const datum of Object.values(progressData)) { - if (items.findIndex(({ session_id }) => session_id === datum.sessionId) === -1) { - toDelete.push(datum.sessionId); + if (items.findIndex(({ item_id }) => item_id === datum.itemId) === -1) { + toDelete.push(datum.itemId); } } if (toDelete.length === 0) { return; } const newProgressData = { ...progressData }; - for (const sessionId of toDelete) { - delete newProgressData[sessionId]; + for (const itemId of toDelete) { + delete newProgressData[itemId]; } // This will re-trigger the effect - maybe this could just be a listener on $items? Brain hurt $progressData.set(newProgressData); }); + // We only want to auto-switch to completed queue items once their images have fully loaded to prevent flashes + // of fallback content and/or progress images. The only surefire way to determine when images have fully loaded + // is via the image elements' `onLoad` callback. Images set `$lastLoadedItemId` to their queue item ID in their + // `onLoad` handler, and we listen for that here. If auto-switch is enabled, we then switch the to the item. + const unsubHandleAutoSwitch = $lastLoadedItemId.listen((lastLoadedItemId) => { + if (lastLoadedItemId === null) { + return; + } + if ($autoSwitch.get()) { + $selectedItemId.set(lastLoadedItemId); + } + $lastLoadedItemId.set(null); + }); + // Create an RTK Query subscription. Without this, the query cache selector will never return anything bc RTK // doesn't know we care about it. const { unsubscribe: unsubQueueItemsQuery } = store.dispatch( @@ -310,6 +318,7 @@ export const CanvasSessionContextProvider = memo( // Clean up all subscriptions and top-level (i.e. non-computed/derived state) return () => { + unsubHandleAutoSwitch(); unsubQueueItemsQuery(); unsubReduxSyncToItemsAtom(); unsubEnsureSelectedItemIdExists(); @@ -318,7 +327,7 @@ export const CanvasSessionContextProvider = memo( $progressData.set({}); $selectedItemId.set(null); }; - }, [$items, $progressData, $selectedItemId, selectQueueItems, session.id, store]); + }, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]); const value = useMemo( () => ({ @@ -330,8 +339,19 @@ export const CanvasSessionContextProvider = memo( $autoSwitch, $selectedItem, $selectedItemIndex, + $lastLoadedItemId, }), - [$autoSwitch, $hasItems, $items, $progressData, $selectedItem, $selectedItemId, $selectedItemIndex, session] + [ + $autoSwitch, + $hasItems, + $items, + $lastLoadedItemId, + $progressData, + $selectedItem, + $selectedItemId, + $selectedItemIndex, + session, + ] ); return {children}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts index 6736e2c306..1723b4bebf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts @@ -21,7 +21,7 @@ export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) = export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))'; -export const getQueueItemElementId = (item_id: number) => `queue-item-status-card-${item_id}`; +export const getQueueItemElementId = (itemId: number) => `queue-item-status-card-${itemId}`; const getOutputImageName = (item: S['SessionQueueItem']) => { const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index b6c8305bfe..0d62110efd 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -1,9 +1,7 @@ import type { EphemeralProgressImage } from 'features/controlLayers/store/types'; import type { ProgressImage } from 'features/nodes/types/common'; import { round } from 'lodash-es'; -import type { WritableAtom } from 'nanostores'; import { atom, computed, map } from 'nanostores'; -import { useEffect, useState } from 'react'; import type { ImageDTO, S } from 'services/api/types'; import type { AppSocket } from 'services/events/types'; import type { ManagerOptions, SocketOptions } from 'socket.io-client'; @@ -27,103 +25,6 @@ export type ProgressData = { progressImage: ProgressImage | null; }; -export const useProgressData = ( - $progressData: WritableAtom>, - sessionId: string -): ProgressData => { - const [value, setValue] = useState(() => { - return $progressData.get()[sessionId] ?? { sessionId, progressEvent: null, progressImage: null }; - }); - useEffect(() => { - const unsub = $progressData.subscribe((data) => { - const progressData = data[sessionId]; - if (!progressData) { - return; - } - setValue(progressData); - }); - return () => { - unsub(); - }; - }, [$progressData, sessionId]); - - return value; -}; - -export const useHasProgressImage = ( - $progressData: WritableAtom>, - sessionId: string -): boolean => { - const [value, setValue] = useState(false); - useEffect(() => { - const unsub = $progressData.subscribe((data) => { - const progressData = data[sessionId]; - setValue(Boolean(progressData?.progressImage)); - }); - return () => { - unsub(); - }; - }, [$progressData, sessionId]); - - return value; -}; - -export const setProgress = ( - $progressData: WritableAtom>, - data: S['InvocationProgressEvent'] -) => { - const progressData = $progressData.get(); - const current = progressData[data.session_id]; - if (current) { - const next = { ...current }; - next.progressEvent = data; - if (data.image) { - next.progressImage = data.image; - } - $progressData.set({ - ...progressData, - [data.session_id]: next, - }); - } else { - $progressData.set({ - ...progressData, - [data.session_id]: { - sessionId: data.session_id, - progressEvent: data, - progressImage: data.image ?? null, - }, - }); - } -}; - -export const clearProgressEvent = ($progressData: WritableAtom>, sessionId: string) => { - const progressData = $progressData.get(); - const current = progressData[sessionId]; - if (!current) { - return; - } - const next = { ...current }; - next.progressEvent = null; - $progressData.set({ - ...progressData, - [sessionId]: next, - }); -}; - -export const clearProgressImage = ($progressData: WritableAtom>, sessionId: string) => { - const progressData = $progressData.get(); - const current = progressData[sessionId]; - if (!current) { - return; - } - const next = { ...current }; - next.progressImage = null; - $progressData.set({ - ...progressData, - [sessionId]: next, - }); -}; - export const $lastCanvasProgressEvent = atom(null); export const $lastCanvasProgressImage = atom(null); export const $lastWorkflowsProgressEvent = atom(null); From 86e1a37a0018b6b062a9f7792828c71955fd413a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 12:01:16 +1000 Subject: [PATCH 049/210] docs(ui): add comment about auto-switch not being quite right yet --- .../controlLayers/components/SimpleSession/context.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 c278e38c84..eea98d42e8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -300,6 +300,10 @@ export const CanvasSessionContextProvider = memo( // of fallback content and/or progress images. The only surefire way to determine when images have fully loaded // is via the image elements' `onLoad` callback. Images set `$lastLoadedItemId` to their queue item ID in their // `onLoad` handler, and we listen for that here. If auto-switch is enabled, we then switch the to the item. + // + // TODO: This isn't perfect... we set $lastLoadedItemId in the mini preview component, but the full view + // component still needs to retrieve the image from the browser cache... can result in a flash of the progress + // image... const unsubHandleAutoSwitch = $lastLoadedItemId.listen((lastLoadedItemId) => { if (lastLoadedItemId === null) { return; From da4b084a8beaf0ee7c064a24f919d7c10dc1e6dc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:44:02 +1000 Subject: [PATCH 050/210] feat(ui): tweak canvas scroll to zoom feel --- .../controlLayers/konva/CanvasStageModule.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts index 1e7c9aebf0..9015017c22 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts @@ -57,6 +57,7 @@ export class CanvasStageModule extends CanvasModuleBase { private _intendedScale: number = 1; private _activeSnapPoint: number | null = null; private _snapTimeout: number | null = null; + private _lastScrollEventTimestamp: number | null = null; container: HTMLDivElement; konva: { stage: Konva.Stage }; @@ -336,10 +337,22 @@ export class CanvasStageModule extends CanvasModuleBase { } // When wheeling on trackpad, e.evt.ctrlKey is true - in that case, let's reverse the direction - const delta = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY; + const scrollAmount = e.evt.ctrlKey ? -e.evt.deltaY : e.evt.deltaY; + + const now = window.performance.now(); + const deltaT = this._lastScrollEventTimestamp === null ? Infinity : now - this._lastScrollEventTimestamp; + this._lastScrollEventTimestamp = now; + + let dynamicScaleFactor = this.config.SCALE_FACTOR; + + if (deltaT > 300) { + dynamicScaleFactor = this.config.SCALE_FACTOR + (1 - this.config.SCALE_FACTOR) / 2; + } else if (deltaT < 300) { + dynamicScaleFactor = this.config.SCALE_FACTOR + (1 - this.config.SCALE_FACTOR) * (deltaT / 200); + } // Update the intended scale based on the last intended scale, creating a continuous zoom feel - const newIntendedScale = this._intendedScale * this.config.SCALE_FACTOR ** delta; + const newIntendedScale = this._intendedScale * dynamicScaleFactor ** scrollAmount; this._intendedScale = this.constrainScale(newIntendedScale); // Pass control to the snapping logic @@ -349,7 +362,7 @@ export class CanvasStageModule extends CanvasModuleBase { // After a short delay, we can reset the intended scale to the current scale // This allows for continuous zooming without snapping back to the last snapped scale this._intendedScale = this.getScale(); - }, 100); + }, 300); }; /** From 5d80642ea4cf6e07056acaedc3e8f13dcee179d8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:45:29 +1000 Subject: [PATCH 051/210] feat(app): support deleting queue items by id or destination --- invokeai/app/api/routers/session_queue.py | 28 ++++++++ .../session_queue/session_queue_base.py | 11 +++ .../session_queue/session_queue_common.py | 6 ++ .../session_queue/session_queue_sqlite.py | 69 +++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 660d09728c..f19f4bc47c 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -14,6 +14,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( CancelByBatchIDsResult, CancelByDestinationResult, ClearResult, + DeleteByDestinationResult, EnqueueBatchResult, FieldIdentifier, PruneResult, @@ -293,6 +294,18 @@ async def get_queue_item( return ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) +@session_queue_router.delete( + "/{queue_id}/i/{item_id}", + operation_id="delete_queue_item", +) +async def delete_queue_item( + queue_id: str = Path(description="The queue id to perform this operation on"), + item_id: int = Path(description="The queue item to delete"), +) -> None: + """Deletes a queue item""" + ApiDependencies.invoker.services.session_queue.delete_queue_item(item_id) + + @session_queue_router.put( "/{queue_id}/i/{item_id}/cancel", operation_id="cancel_queue_item", @@ -322,3 +335,18 @@ async def counts_by_destination( return ApiDependencies.invoker.services.session_queue.get_counts_by_destination( queue_id=queue_id, destination=destination ) + + +@session_queue_router.delete( + "/{queue_id}/d/{destination}", + operation_id="delete_by_destination", + responses={200: {"model": DeleteByDestinationResult}}, +) +async def delete_by_destination( + queue_id: str = Path(description="The queue id to query"), + destination: str = Path(description="The destination to query"), +) -> DeleteByDestinationResult: + """Deletes all items with the given destination""" + return ApiDependencies.invoker.services.session_queue.delete_by_destination( + queue_id=queue_id, destination=destination + ) diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index a2aa844742..17c31e77a7 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -10,6 +10,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( CancelByDestinationResult, CancelByQueueIDResult, ClearResult, + DeleteByDestinationResult, EnqueueBatchResult, IsEmptyResult, IsFullResult, @@ -91,6 +92,11 @@ class SessionQueueBase(ABC): """Cancels a session queue item""" pass + @abstractmethod + def delete_queue_item(self, item_id: int) -> None: + """Deletes a session queue item""" + pass + @abstractmethod def fail_queue_item( self, item_id: int, error_type: str, error_message: str, error_traceback: str @@ -108,6 +114,11 @@ class SessionQueueBase(ABC): """Cancels all queue items with the given batch destination""" pass + @abstractmethod + def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult: + """Deletes all queue items with the given batch destination""" + pass + @abstractmethod def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: """Cancels all queue items with matching queue ID""" diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index 1861110c97..d41fb44533 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -363,6 +363,12 @@ class CancelByDestinationResult(CancelByBatchIDsResult): pass +class DeleteByDestinationResult(BaseModel): + """Result of deleting by a destination""" + + deleted: int = Field(..., description="Number of queue items deleted") + + class CancelByQueueIDResult(CancelByBatchIDsResult): """Result of canceling by queue id""" diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 0a0ef72456..c31226581a 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -17,6 +17,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( CancelByDestinationResult, CancelByQueueIDResult, ClearResult, + DeleteByDestinationResult, EnqueueBatchResult, IsEmptyResult, IsFullResult, @@ -212,6 +213,19 @@ class SqliteSessionQueue(SessionQueueBase): ) -> SessionQueueItem: try: cursor = self._conn.cursor() + cursor.execute( + """--sql + SELECT status FROM session_queue WHERE item_id = ? + """, + (item_id,), + ) + row = cursor.fetchone() + if row is None: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + current_status = row[0] + # Only update if not already finished (completed, failed or canceled) + if current_status in ("completed", "failed", "canceled"): + return self.get_queue_item(item_id) cursor.execute( """--sql UPDATE session_queue @@ -323,6 +337,27 @@ class SqliteSessionQueue(SessionQueueBase): queue_item = self._set_queue_item_status(item_id=item_id, status="canceled") return queue_item + def delete_queue_item(self, item_id: int) -> None: + """Deletes a session queue item""" + try: + self.cancel_queue_item(item_id) + except SessionQueueItemNotFoundError: + pass + try: + cursor = self._conn.cursor() + cursor.execute( + """--sql + DELETE + FROM session_queue + WHERE item_id = ? + """, + (item_id,), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + def complete_queue_item(self, item_id: int) -> SessionQueueItem: queue_item = self._set_queue_item_status(item_id=item_id, status="completed") return queue_item @@ -420,6 +455,40 @@ class SqliteSessionQueue(SessionQueueBase): raise return CancelByDestinationResult(canceled=count) + def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult: + try: + cursor = self._conn.cursor() + current_queue_item = self.get_current(queue_id) + if current_queue_item is not None and current_queue_item.destination == destination: + self.cancel_queue_item(current_queue_item.item_id) + params = (queue_id, destination) + cursor.execute( + """--sql + SELECT COUNT(*) + FROM session_queue + WHERE + queue_id = ? + AND destination = ?; + """, + params, + ) + count = cursor.fetchone()[0] + cursor.execute( + """--sql + DELETE + FROM session_queue + WHERE + queue_id = ? + AND destination = ?; + """, + params, + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + return DeleteByDestinationResult(deleted=count) + def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: try: cursor = self._conn.cursor() From 56938ca0a1e611ec01f0a8cf8c62aaba75e107d9 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:45:56 +1000 Subject: [PATCH 052/210] feat(ui): rough out canvas staging area --- .../addCommitStagingAreaImageListener.ts | 2 +- .../listeners/imageDeletionListeners.ts | 2 +- .../AdvancedSession/AdvancedSession.tsx | 33 +++-- .../SimpleSession/QueueItemPreviewMini.tsx | 14 +- .../SimpleSession/QueueItemProgressImage.tsx | 9 +- .../SimpleSession/StagingAreaItemsList.tsx | 37 ++++- .../components/SimpleSession/context.tsx | 132 ++++++++++++++++- .../components/SimpleSession/shared.ts | 2 +- .../SimpleSession/use-staging-keyboard-nav.ts | 54 +------ .../StagingArea/StagingAreaToolbar.tsx | 17 ++- .../StagingAreaToolbarAcceptButton.tsx | 20 +-- .../StagingAreaToolbarDiscardAllButton.tsx | 11 +- ...tagingAreaToolbarDiscardSelectedButton.tsx | 25 ++-- .../StagingAreaToolbarImageCountButton.tsx | 11 +- .../StagingAreaToolbarNextButton.tsx | 20 +-- .../StagingAreaToolbarPrevButton.tsx | 20 +-- .../StagingAreaToolbarSaveAsMenu.tsx | 86 +++++------ ...AreaToolbarSaveSelectedToGalleryButton.tsx | 15 +- .../controlLayers/hooks/useInvokeCanvas.ts | 5 + .../controlLayers/konva/CanvasManager.ts | 6 - .../konva/CanvasObject/CanvasObjectImage.ts | 110 +++++++++----- .../konva/CanvasStagingAreaModule.ts | 138 +++++++----------- .../src/features/controlLayers/store/types.ts | 9 +- .../src/features/controlLayers/store/util.ts | 17 +++ .../deleteImageModal/store/selectors.ts | 6 +- .../queue/hooks/useCancelCurrentQueueItem.ts | 4 +- .../queue/hooks/useCancelQueueItem.ts | 2 +- .../web/src/services/api/endpoints/images.ts | 7 + .../web/src/services/api/endpoints/queue.ts | 34 ++++- .../frontend/web/src/services/api/schema.ts | 105 ++++++++++++- 30 files changed, 622 insertions(+), 331 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts index a83da52d21..6ef2af6c2f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts @@ -17,7 +17,7 @@ export const addStagingListeners = (startAppListening: AppStartListening) => { effect: async (_, { dispatch }) => { try { const req = dispatch( - queueApi.endpoints.cancelByBatchDestination.initiate( + queueApi.endpoints.cancelByDestination.initiate( { destination: 'canvas' }, { fixedCacheKey: 'cancelByBatchOrigin' } ) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts index f0fd91e69b..150cb73e83 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts @@ -58,7 +58,7 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => { let shouldDelete = false; for (const obj of objects) { - if (obj.type === 'image' && obj.image.image_name === imageDTO.image_name) { + if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) { shouldDelete = true; break; } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx index 73f50ca36f..47fd674016 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx @@ -13,7 +13,8 @@ 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 { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate'; +import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context'; +import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; @@ -52,7 +53,7 @@ const canvasBgSx = { }, }; -export const AdvancedSession = memo((_props: { session: AdvancedSessionIdentifier }) => { +export const AdvancedSession = memo(({ session }: { session: AdvancedSessionIdentifier }) => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); @@ -106,13 +107,27 @@ export const AdvancedSession = memo((_props: { session: AdvancedSessionIdentifie )} - - - - - - - + + + + + + + + + + + + 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 2578731bfa..acb907325b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -11,24 +11,18 @@ import { memo, useCallback, useState } from 'react'; import type { S } from 'services/api/types'; const sx = { - cursor: 'pointer', userSelect: 'none', pos: 'relative', alignItems: 'center', justifyContent: 'center', - overflow: 'hidden', - h: 'full', - maxH: 'full', - maxW: 'full', - minW: 0, - minH: 0, + h: 108, + w: 108, + flexShrink: 0, borderWidth: 1, borderRadius: 'base', '&[data-selected="true"]': { borderColor: 'invokeBlue.300', }, - aspectRatio: '1/1', - flexShrink: 0, } satisfies SystemStyleObject; type Props = { @@ -64,7 +58,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = onDoubleClick={onDoubleClick} > - {imageDTO && } + {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 2ea3dd827e..c21e41e12a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -1,7 +1,8 @@ import type { ImageProps } from '@invoke-ai/ui-library'; -import { Image } from '@invoke-ai/ui-library'; +import { Flex, Icon, Image } from '@invoke-ai/ui-library'; import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { memo } from 'react'; +import { PiImageBold } from 'react-icons/pi'; type Props = { itemId: number } & ImageProps; @@ -10,7 +11,11 @@ export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => { const { progressImage } = useProgressData(ctx.$progressData, itemId); if (!progressImage) { - return null; + return ( + + + + ); } return ( 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 d679835707..3e9b9cee37 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx @@ -4,16 +4,49 @@ import { useStore } from '@nanostores/react'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini'; -import { memo } from 'react'; +import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; +import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { effect } from 'nanostores'; +import { memo, useEffect } from 'react'; export const StagingAreaItemsList = memo(() => { + const canvasManager = useCanvasManagerSafe(); const ctx = useCanvasSessionContext(); const items = useStore(ctx.$items); const selectedItemId = useStore(ctx.$selectedItemId); + useEffect(() => { + if (!canvasManager) { + return; + } + + return effect([ctx.$selectedItem, ctx.$progressData], (selectedItem, progressData) => { + if (!selectedItem) { + canvasManager.stagingArea.render(); + return; + } + + const outputImageName = getOutputImageName(selectedItem); + + if (outputImageName) { + canvasManager.stagingArea.render({ type: 'imageName', data: outputImageName }); + return; + } + + const data = progressData[selectedItem.item_id]; + + if (data?.progressImage) { + canvasManager.stagingArea.render({ type: 'dataURL', data: data.progressImage.dataURL }); + return; + } + + canvasManager.stagingArea.render(); + }); + }, [canvasManager, ctx.$progressData, ctx.$selectedItem]); + return ( - + {items.map((item, i) => ( ; + $itemCount: Atom; $hasItems: Atom; $progressData: WritableAtom>; $selectedItemId: WritableAtom; $selectedItem: Atom; $selectedItemIndex: Atom; + $selectedItemOutputImageName: Atom; $autoSwitch: WritableAtom; $lastLoadedItemId: WritableAtom; + selectNext: () => void; + selectPrev: () => void; + selectFirst: () => void; + selectLast: () => void; }; const CanvasSessionContext = createContext(null); @@ -153,6 +160,11 @@ export const CanvasSessionContextProvider = memo( */ const $items = useState(() => atom([]))[0]; + /** + * Manually-synced atom containing the queue items for the current session. + */ + const $prevItems = useState(() => atom([]))[0]; + /** * Whether auto-switch is enabled. */ @@ -174,6 +186,11 @@ export const CanvasSessionContextProvider = memo( */ 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. */ @@ -209,6 +226,23 @@ export const CanvasSessionContextProvider = memo( }) )[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 $selectedItemOutputImageName = useState(() => + computed([$selectedItem], (selectedItem) => { + if (selectedItem === null) { + return null; + } + const outputImageName = getOutputImageName(selectedItem); + if (outputImageName === null) { + return null; + } + return outputImageName; + }) + )[0]; + /** * A redux selector to select all queue items from the RTK Query cache. It's important that this returns stable * references if possible to reduce re-renders. All derivations of the queue items (e.g. filtering out canceled @@ -223,6 +257,54 @@ export const CanvasSessionContextProvider = memo( [session.id] ); + 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]); + // Set up socket listeners useEffect(() => { if (!socket) { @@ -236,10 +318,23 @@ export const CanvasSessionContextProvider = memo( setProgress($progressData, data); }; + const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { + if (data.destination !== session.id) { + return; + } + + if (data.status === 'canceled' || data.status === 'failed') { + clearProgressEvent($progressData, data.item_id); + clearProgressImage($progressData, 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); }; }, [$autoSwitch, $progressData, $selectedItemId, session.id, socket]); @@ -253,6 +348,7 @@ export const CanvasSessionContextProvider = memo( const prevItems = $items.get(); const items = selectQueueItems(store.getState()); if (items !== prevItems) { + $prevItems.set(prevItems); $items.set(items); } }); @@ -272,13 +368,16 @@ export const CanvasSessionContextProvider = memo( // 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. if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) { - $selectedItemId.set(null); + const prevIndex = $prevItems.get().findIndex(({ item_id }) => item_id === selectedItemId); + const nextItem = items[prevIndex]; + $selectedItemId.set(nextItem?.item_id ?? null); return; } }); // Clean up the progress data when a queue item is discarded. - const unsubCleanUpProgressData = effect([$items, $progressData], (items, progressData) => { + const unsubCleanUpProgressData = $items.listen((items) => { + const progressData = $progressData.get(); const toDelete: number[] = []; for (const datum of Object.values(progressData)) { if (items.findIndex(({ item_id }) => item_id === datum.itemId) === -1) { @@ -292,7 +391,6 @@ export const CanvasSessionContextProvider = memo( for (const itemId of toDelete) { delete newProgressData[itemId]; } - // This will re-trigger the effect - maybe this could just be a listener on $items? Brain hurt $progressData.set(newProgressData); }); @@ -331,7 +429,17 @@ export const CanvasSessionContextProvider = memo( $progressData.set({}); $selectedItemId.set(null); }; - }, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]); + }, [ + $autoSwitch, + $items, + $lastLoadedItemId, + $prevItems, + $progressData, + $selectedItemId, + selectQueueItems, + session.id, + store, + ]); const value = useMemo( () => ({ @@ -344,17 +452,29 @@ export const CanvasSessionContextProvider = memo( $selectedItem, $selectedItemIndex, $lastLoadedItemId, + $selectedItemOutputImageName, + $itemCount, + selectNext, + selectPrev, + selectFirst, + selectLast, }), [ $autoSwitch, - $hasItems, $items, + $hasItems, $lastLoadedItemId, $progressData, $selectedItem, $selectedItemId, $selectedItemIndex, session, + $selectedItemOutputImageName, + $itemCount, + selectNext, + selectPrev, + selectFirst, + selectLast, ] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts index 1723b4bebf..d8b7ebc7b1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts @@ -23,7 +23,7 @@ export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0p export const getQueueItemElementId = (itemId: number) => `queue-item-status-card-${itemId}`; -const getOutputImageName = (item: S['SessionQueueItem']) => { +export const getOutputImageName = (item: S['SessionQueueItem']) => { const nodeId = Object.entries(item.session.source_prepared_mapping).find(([nodeId]) => isCanvasOutputNodeId(nodeId) )?.[1][0]; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts index 6a11b5fa99..bb3989b565 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/use-staging-keyboard-nav.ts @@ -1,57 +1,11 @@ import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; export const useStagingAreaKeyboardNav = () => { const ctx = useCanvasSessionContext(); - const onNext = useCallback(() => { - const selectedItemId = ctx.$selectedItemId.get(); - if (selectedItemId === null) { - return; - } - const items = ctx.$items.get(); - const currentIndex = items.findIndex((item) => item.item_id === selectedItemId); - const nextIndex = (currentIndex + 1) % items.length; - const nextItem = items[nextIndex]; - if (!nextItem) { - return; - } - ctx.$selectedItemId.set(nextItem.item_id); - }, [ctx.$items, ctx.$selectedItemId]); - const onPrev = useCallback(() => { - const selectedItemId = ctx.$selectedItemId.get(); - if (selectedItemId === null) { - return; - } - const items = ctx.$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; - } - ctx.$selectedItemId.set(prevItem.item_id); - }, [ctx.$items, ctx.$selectedItemId]); - const onFirst = useCallback(() => { - const items = ctx.$items.get(); - const first = items.at(0); - if (!first) { - return; - } - ctx.$selectedItemId.set(first.item_id); - }, [ctx.$items, ctx.$selectedItemId]); - const onLast = useCallback(() => { - const items = ctx.$items.get(); - const last = items.at(-1); - if (!last) { - return; - } - ctx.$selectedItemId.set(last.item_id); - }, [ctx.$items, ctx.$selectedItemId]); - - useHotkeys('left', onPrev, { preventDefault: true }); - useHotkeys('right', onNext, { preventDefault: true }); - useHotkeys('meta+left', onFirst, { preventDefault: true }); - useHotkeys('meta+right', onLast, { preventDefault: true }); + useHotkeys('left', ctx.selectPrev, { preventDefault: true }); + useHotkeys('right', ctx.selectNext, { preventDefault: true }); + useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true }); + useHotkeys('meta+right', ctx.selectLast, { preventDefault: true }); }; 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 1404616380..4ac13e74a3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -1,4 +1,6 @@ import { ButtonGroup } from '@invoke-ai/ui-library'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton'; import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; @@ -8,9 +10,22 @@ import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/ import { StagingAreaToolbarSaveAsMenu } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu'; import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton'; import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; export const StagingAreaToolbar = memo(() => { + const ctx = useCanvasSessionContext(); + + useEffect(() => { + return ctx.$selectedItemId.listen((id) => { + if (id !== null) { + document.getElementById(getQueueItemElementId(id))?.scrollIntoView(); + } + }); + }, [ctx.$selectedItemId]); + + useHotkeys('meta+left', ctx.selectFirst, { preventDefault: true }); + useHotkeys('meta+right', ctx.selectLast, { preventDefault: true }); 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 7d01422854..3a1feddebc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -2,48 +2,48 @@ 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 { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; import { selectImageCount, - selectSelectedImage, stagingAreaReset, } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; -import { imageDTOToImageObject } from 'features/controlLayers/store/util'; +import { imageNameToImageObject } from 'features/controlLayers/store/util'; import { memo, useCallback } 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 canvasManager = useCanvasManager(); const bboxRect = useAppSelector(selectBboxRect); - const selectedImage = useAppSelector(selectSelectedImage); const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); const imageCount = useAppSelector(selectImageCount); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); + const selectedItemImageName = useStore(ctx.$selectedItemOutputImageName); const { t } = useTranslation(); const acceptSelected = useCallback(() => { - if (!selectedImage) { + if (!selectedItemImageName) { return; } - const { x, y } = bboxRect; - const { imageDTO, offsetX, offsetY } = selectedImage; - const imageObject = imageDTOToImageObject(imageDTO); + const { x, y, width, height } = bboxRect; + const imageObject = imageNameToImageObject(selectedItemImageName, { width, height }); const overrides: Partial = { - position: { x: x + offsetX, y: y + offsetY }, + position: { x, y }, objects: [imageObject], }; dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' })); dispatch(stagingAreaReset()); - }, [bboxRect, dispatch, selectedEntityIdentifier?.type, selectedImage]); + }, [bboxRect, selectedItemImageName, dispatch, selectedEntityIdentifier?.type]); useHotkeys( ['enter'], @@ -62,7 +62,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => { icon={} onClick={acceptSelected} colorScheme="invokeBlue" - isDisabled={!selectedImage} + isDisabled={!selectedItemImageName} /> ); }); 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 ee038c075e..dbc978a42a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx @@ -1,17 +1,18 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { stagingAreaReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; +import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarDiscardAllButton = memo(() => { - const dispatch = useAppDispatch(); + const ctx = useCanvasSessionContext(); const { t } = useTranslation(); + const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation(); const discardAll = useCallback(() => { - dispatch(stagingAreaReset()); - }, [dispatch]); + deleteByDestination({ destination: ctx.session.id }); + }, [deleteByDestination, ctx.session.id]); return ( { + const ctx = useCanvasSessionContext(); const dispatch = useAppDispatch(); + const [deleteQueueItem] = useDeleteQueueItemMutation(); + const selectedItemId = useStore(ctx.$selectedItemId); const index = useAppSelector(selectStagedImageIndex); const selectedImage = useAppSelector(selectSelectedImage); const imageCount = useAppSelector(selectImageCount); @@ -20,15 +24,16 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(() => { const { t } = useTranslation(); const discardSelected = useCallback(() => { - if (!selectedImage) { + if (selectedItemId === null) { return; } - if (imageCount === 1) { - dispatch(stagingAreaReset()); - } else { - dispatch(stagingAreaStagedImageDiscarded({ index })); - } - }, [selectedImage, imageCount, dispatch, index]); + deleteQueueItem({ item_id: selectedItemId }); + // if (imageCount === 1) { + // dispatch(stagingAreaReset()); + // } else { + // dispatch(stagingAreaStagedImageDiscarded({ index })); + // } + }, [selectedItemId, deleteQueueItem]); return ( { onClick={discardSelected} colorScheme="invokeBlue" fontSize={16} - isDisabled={!selectedImage} + isDisabled={selectedItemId === null} /> ); }); 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 d408bf1c90..7e14ff580a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx @@ -1,19 +1,24 @@ import { Button } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { selectImageCount, selectStagedImageIndex } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useMemo } from 'react'; export const StagingAreaToolbarImageCountButton = memo(() => { + const ctx = useCanvasSessionContext(); + const selectItemIndex = useStore(ctx.$selectedItemIndex); + const itemCount = useStore(ctx.$itemCount); const index = useAppSelector(selectStagedImageIndex); const imageCount = useAppSelector(selectImageCount); const counterText = useMemo(() => { - if (imageCount > 0) { - return `${(index ?? 0) + 1} of ${imageCount}`; + if (itemCount > 0 && selectItemIndex !== null) { + return `${selectItemIndex + 1} of ${itemCount}`; } else { return `0 of 0`; } - }, [imageCount, index]); + }, [itemCount, selectItemIndex]); return ( - ); -}; - -export const CanvasAlertsSendingToGallery = () => { - const { t } = useTranslation(); - const destination = useCurrentDestination(); - const tab = useAppSelector(selectActiveTab); - const isVisible = useMemo(() => { - // This alert should only be visible when the destination is gallery and the tab is canvas - if (tab !== 'canvas') { - return false; - } - if (!destination) { - return false; - } - - return destination === 'gallery'; - }, [destination, tab]); - - return ( - }} /> - } - isVisible={isVisible} - /> - ); -}; - -const ActivateCanvasButton = (props: PropsWithChildren) => { - const dispatch = useAppDispatch(); - const imageViewer = useImageViewer(); - const onClick = useCallback(() => { - dispatch(setActiveTab('canvas')); - dispatch(activeTabCanvasRightPanelChanged('layers')); - imageViewer.close(); - }, [dispatch, imageViewer]); - return ( - - ); -}; - -export const CanvasAlertsSendingToCanvas = () => { - const { t } = useTranslation(); - const destination = useCurrentDestination(); - const isStaging = useAppSelector(selectIsStaging); - const tab = useAppSelector(selectActiveTab); - const isVisible = useMemo(() => { - // When we are on a non-canvas tab, and the current generation's destination is not the canvas, we don't show the alert - // For example, on the workflows tab, when the destinatin is gallery, we don't show the alert - if (tab !== 'canvas' && destination !== 'canvas') { - return false; - } - if (isStaging) { - return true; - } - - if (!destination) { - return false; - } - - return destination === 'canvas'; - }, [destination, isStaging, tab]); - - return ( - }} /> - } - isVisible={isVisible} - /> - ); -}; - -const AlertWrapper = ({ - title, - description, - isVisible, -}: { - title: ReactNode; - description: ReactNode; - isVisible: boolean; -}) => { - const isHovered = useBoolean(false); - - return ( - - {(isVisible || isHovered.isTrue) && ( - - - - - {title} - - {description} - - - )} - - ); -}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx deleted file mode 100644 index 4e1b3d2c69..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; -import { Box, Button, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; -import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; -import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; -import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; -import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; -import type { DndTargetState } from 'features/dnd/types'; -import RightPanelContent from 'features/gallery/components/GalleryTopBar'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; -import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const CanvasRightPanel = memo(() => { - const { t } = useTranslation(); - const activeTab = useAppSelector(selectActiveTabCanvasRightPanel); - const imageViewer = useImageViewer(); - const dispatch = useAppDispatch(); - - const tabIndex = useMemo(() => { - if (activeTab === 'gallery') { - return 1; - } else { - return 0; - } - }, [activeTab]); - - const onClickViewerToggleButton = useCallback(() => { - imageViewer.open(); - }, [imageViewer]); - - const onChangeTab = useCallback( - (index: number) => { - if (index === 0) { - dispatch(activeTabCanvasRightPanelChanged('layers')); - } else { - dispatch(activeTabCanvasRightPanelChanged('gallery')); - } - }, - [dispatch] - ); - - return ( - - - - - - - - - - - - - - - - - - ); -}); - -CanvasRightPanel.displayName = 'CanvasRightPanel'; - -const PanelTabs = memo(() => { - const { t } = useTranslation(); - const store = useAppStore(); - const activeEntityCount = useAppSelector(selectEntityCountActive); - const [layersTabDndState, setLayersTabDndState] = useState('idle'); - const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); - const layersTabRef = useRef(null); - const galleryTabRef = useRef(null); - const timeoutRef = useRef(null); - - const layersTabLabel = useMemo(() => { - if (activeEntityCount === 0) { - return t('controlLayers.layer_other'); - } - return `${t('controlLayers.layer_other')} (${activeEntityCount})`; - }, [activeEntityCount, t]); - - useEffect(() => { - if (!layersTabRef.current) { - return; - } - - const getIsOnLayersTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'layers'; - - const onDragEnter = () => { - // If we are already on the layers tab, do nothing - if (getIsOnLayersTab()) { - return; - } - - // Else set the state to active and switch to the layers tab after a timeout - setLayersTabDndState('over'); - timeoutRef.current = window.setTimeout(() => { - timeoutRef.current = null; - store.dispatch(activeTabCanvasRightPanelChanged('layers')); - // When we switch tabs, the other tab should be pending - setLayersTabDndState('idle'); - setGalleryTabDndState('potential'); - }, 300); - }; - const onDragLeave = () => { - // Set the state to idle or pending depending on the current tab - if (getIsOnLayersTab()) { - setLayersTabDndState('idle'); - } else { - setLayersTabDndState('potential'); - } - // Abort the tab switch if it hasn't happened yet - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - const onDragStart = () => { - // Set the state to pending when a drag starts - setLayersTabDndState('potential'); - }; - return combine( - dropTargetForElements({ - element: layersTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForElements({ - canMonitor: ({ source }) => { - if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { - return false; - } - // Only monitor if we are not already on the gallery tab - return !getIsOnLayersTab(); - }, - onDragStart, - }), - dropTargetForExternal({ - element: layersTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForExternal({ - canMonitor: () => !getIsOnLayersTab(), - onDragStart, - }) - ); - }, [store]); - - useEffect(() => { - if (!galleryTabRef.current) { - return; - } - - const getIsOnGalleryTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'gallery'; - - const onDragEnter = () => { - // If we are already on the gallery tab, do nothing - if (getIsOnGalleryTab()) { - return; - } - - // Else set the state to active and switch to the gallery tab after a timeout - setGalleryTabDndState('over'); - timeoutRef.current = window.setTimeout(() => { - timeoutRef.current = null; - store.dispatch(activeTabCanvasRightPanelChanged('gallery')); - // When we switch tabs, the other tab should be pending - setGalleryTabDndState('idle'); - setLayersTabDndState('potential'); - }, 300); - }; - - const onDragLeave = () => { - // Set the state to idle or pending depending on the current tab - if (getIsOnGalleryTab()) { - setGalleryTabDndState('idle'); - } else { - setGalleryTabDndState('potential'); - } - // Abort the tab switch if it hasn't happened yet - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - - const onDragStart = () => { - // Set the state to pending when a drag starts - setGalleryTabDndState('potential'); - }; - - return combine( - dropTargetForElements({ - element: galleryTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForElements({ - canMonitor: ({ source }) => { - if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { - return false; - } - // Only monitor if we are not already on the gallery tab - return !getIsOnGalleryTab(); - }, - onDragStart, - }), - dropTargetForExternal({ - element: galleryTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForExternal({ - canMonitor: () => !getIsOnGalleryTab(), - onDragStart, - }) - ); - }, [store]); - - useEffect(() => { - const onDrop = () => { - // Reset the dnd state when a drop happens - setGalleryTabDndState('idle'); - setLayersTabDndState('idle'); - }; - const cleanup = combine(monitorForElements({ onDrop }), monitorForExternal({ onDrop })); - - return () => { - cleanup(); - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - return ( - <> - - - {layersTabLabel} - - - - - - {t('gallery.gallery')} - - - - - ); -}); - -PanelTabs.displayName = 'PanelTabs'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx deleted file mode 100644 index 4806728f27..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanelStacked.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; -import { Box, Tab } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; -import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; -import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; -import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; -import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; -import type { DndTargetState } from 'features/dnd/types'; -import RightPanelContent from 'features/gallery/components/GalleryTopBar'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; -import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; - -export const CanvasRightPanelStacked = memo(() => { - const { t } = useTranslation(); - const activeTab = useAppSelector(selectActiveTabCanvasRightPanel); - const imageViewer = useImageViewer(); - const dispatch = useAppDispatch(); - - const tabIndex = useMemo(() => { - if (activeTab === 'gallery') { - return 1; - } else { - return 0; - } - }, [activeTab]); - - const onClickViewerToggleButton = useCallback(() => { - imageViewer.open(); - }, [imageViewer]); - - const onChangeTab = useCallback( - (index: number) => { - if (index === 0) { - dispatch(activeTabCanvasRightPanelChanged('layers')); - } else { - dispatch(activeTabCanvasRightPanelChanged('gallery')); - } - }, - [dispatch] - ); - - return ( - - - - - - - - - - - - ); -}); - -CanvasRightPanelStacked.displayName = 'CanvasRightPanelStacked'; - -const PanelTabs = memo(() => { - const { t } = useTranslation(); - const store = useAppStore(); - const activeEntityCount = useAppSelector(selectEntityCountActive); - const [layersTabDndState, setLayersTabDndState] = useState('idle'); - const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); - const layersTabRef = useRef(null); - const galleryTabRef = useRef(null); - const timeoutRef = useRef(null); - - const layersTabLabel = useMemo(() => { - if (activeEntityCount === 0) { - return t('controlLayers.layer_other'); - } - return `${t('controlLayers.layer_other')} (${activeEntityCount})`; - }, [activeEntityCount, t]); - - useEffect(() => { - if (!layersTabRef.current) { - return; - } - - const getIsOnLayersTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'layers'; - - const onDragEnter = () => { - // If we are already on the layers tab, do nothing - if (getIsOnLayersTab()) { - return; - } - - // Else set the state to active and switch to the layers tab after a timeout - setLayersTabDndState('over'); - timeoutRef.current = window.setTimeout(() => { - timeoutRef.current = null; - store.dispatch(activeTabCanvasRightPanelChanged('layers')); - // When we switch tabs, the other tab should be pending - setLayersTabDndState('idle'); - setGalleryTabDndState('potential'); - }, 300); - }; - const onDragLeave = () => { - // Set the state to idle or pending depending on the current tab - if (getIsOnLayersTab()) { - setLayersTabDndState('idle'); - } else { - setLayersTabDndState('potential'); - } - // Abort the tab switch if it hasn't happened yet - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - const onDragStart = () => { - // Set the state to pending when a drag starts - setLayersTabDndState('potential'); - }; - return combine( - dropTargetForElements({ - element: layersTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForElements({ - canMonitor: ({ source }) => { - if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { - return false; - } - // Only monitor if we are not already on the gallery tab - return !getIsOnLayersTab(); - }, - onDragStart, - }), - dropTargetForExternal({ - element: layersTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForExternal({ - canMonitor: () => !getIsOnLayersTab(), - onDragStart, - }) - ); - }, [store]); - - useEffect(() => { - if (!galleryTabRef.current) { - return; - } - - const getIsOnGalleryTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'gallery'; - - const onDragEnter = () => { - // If we are already on the gallery tab, do nothing - if (getIsOnGalleryTab()) { - return; - } - - // Else set the state to active and switch to the gallery tab after a timeout - setGalleryTabDndState('over'); - timeoutRef.current = window.setTimeout(() => { - timeoutRef.current = null; - store.dispatch(activeTabCanvasRightPanelChanged('gallery')); - // When we switch tabs, the other tab should be pending - setGalleryTabDndState('idle'); - setLayersTabDndState('potential'); - }, 300); - }; - - const onDragLeave = () => { - // Set the state to idle or pending depending on the current tab - if (getIsOnGalleryTab()) { - setGalleryTabDndState('idle'); - } else { - setGalleryTabDndState('potential'); - } - // Abort the tab switch if it hasn't happened yet - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - - const onDragStart = () => { - // Set the state to pending when a drag starts - setGalleryTabDndState('potential'); - }; - - return combine( - dropTargetForElements({ - element: galleryTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForElements({ - canMonitor: ({ source }) => { - if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { - return false; - } - // Only monitor if we are not already on the gallery tab - return !getIsOnGalleryTab(); - }, - onDragStart, - }), - dropTargetForExternal({ - element: galleryTabRef.current, - onDragEnter, - onDragLeave, - }), - monitorForExternal({ - canMonitor: () => !getIsOnGalleryTab(), - onDragStart, - }) - ); - }, [store]); - - useEffect(() => { - const onDrop = () => { - // Reset the dnd state when a drop happens - setGalleryTabDndState('idle'); - setLayersTabDndState('idle'); - }; - const cleanup = combine(monitorForElements({ onDrop }), monitorForExternal({ onDrop })); - - return () => { - cleanup(); - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - return ( - <> - - - {layersTabLabel} - - - - - - {t('gallery.gallery')} - - - - - ); -}); - -PanelTabs.displayName = 'PanelTabs'; 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 5c8b667a3c..d4089d761d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -17,7 +17,7 @@ import type { S } from 'services/api/types'; import { $socket } from 'services/events/stores'; import { assert } from 'tsafe'; -export type ProgressData = { +type ProgressData = { itemId: number; progressEvent: S['InvocationProgressEvent'] | null; progressImage: ProgressImage | null; @@ -46,28 +46,7 @@ export const useProgressData = ( return value; }; -export const useHasProgressImage = ( - $progressData: WritableAtom>, - itemId: number -): boolean => { - const [value, setValue] = useState(false); - useEffect(() => { - const unsub = $progressData.subscribe((data) => { - const progressData = data[itemId]; - setValue(Boolean(progressData?.progressImage)); - }); - return () => { - unsub(); - }; - }, [$progressData, itemId]); - - return value; -}; - -export const setProgress = ( - $progressData: WritableAtom>, - data: S['InvocationProgressEvent'] -) => { +const setProgress = ($progressData: WritableAtom>, data: S['InvocationProgressEvent']) => { const progressData = $progressData.get(); const current = progressData[data.item_id]; if (current) { @@ -120,7 +99,7 @@ export const clearProgressImage = ($progressData: WritableAtom; $itemCount: Atom; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaIsStagingGate.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaIsStagingGate.tsx deleted file mode 100644 index d66e30d0c0..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaIsStagingGate.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useAppSelector } from 'app/store/storeHooks'; -import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; -import { useGetQueueCountsByDestinationQuery } from 'services/api/endpoints/queue'; - -// This hook just serves as a persistent subscriber for the queue count query. -const queueCountArg = { destination: 'canvas' }; -const useCanvasQueueCountWatcher = () => { - useGetQueueCountsByDestinationQuery(queueCountArg); -}; - -export const StagingAreaIsStagingGate = memo((props: PropsWithChildren) => { - useCanvasQueueCountWatcher(); - const isStaging = useAppSelector(selectIsStaging); - - if (!isStaging) { - return null; - } - - return props.children; -}); - -StagingAreaIsStagingGate.displayName = 'StagingAreaIsStagingGate'; 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 3a1feddebc..147b473af7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -5,10 +5,7 @@ import { useIsRegionFocused } from 'common/hooks/focus'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; -import { - selectImageCount, - stagingAreaReset, -} from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectImageCount, stagingAreaReset } 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'; 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 43a548aec0..c577bc4fa6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -1,12 +1,6 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { - selectImageCount, - selectSelectedImage, - selectStagedImageIndex, -} from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; @@ -14,12 +8,8 @@ import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarDiscardSelectedButton = memo(() => { const ctx = useCanvasSessionContext(); - const dispatch = useAppDispatch(); const [deleteQueueItem] = useDeleteQueueItemMutation(); const selectedItemId = useStore(ctx.$selectedItemId); - const index = useAppSelector(selectStagedImageIndex); - const selectedImage = useAppSelector(selectSelectedImage); - const imageCount = useAppSelector(selectImageCount); const { t } = useTranslation(); @@ -28,11 +18,6 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(() => { return; } deleteQueueItem({ item_id: selectedItemId }); - // if (imageCount === 1) { - // dispatch(stagingAreaReset()); - // } else { - // dispatch(stagingAreaStagedImageDiscarded({ index })); - // } }, [selectedItemId, deleteQueueItem]); return ( 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 7e14ff580a..446dea8836 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton.tsx @@ -1,16 +1,12 @@ import { Button } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useAppSelector } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { selectImageCount, selectStagedImageIndex } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useMemo } from 'react'; export const StagingAreaToolbarImageCountButton = memo(() => { const ctx = useCanvasSessionContext(); const selectItemIndex = useStore(ctx.$selectedItemIndex); const itemCount = useStore(ctx.$itemCount); - const index = useAppSelector(selectStagedImageIndex); - const imageCount = useAppSelector(selectImageCount); const counterText = useMemo(() => { if (itemCount > 0 && selectItemIndex !== null) { 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 8d60420de2..0a5b5e50de 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx @@ -1,10 +1,8 @@ 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 { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectImageCount } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; @@ -13,9 +11,7 @@ import { PiArrowRightBold } from 'react-icons/pi'; export const StagingAreaToolbarNextButton = memo(() => { const ctx = useCanvasSessionContext(); const itemCount = useStore(ctx.$itemCount); - const dispatch = useAppDispatch(); const canvasManager = useCanvasManager(); - const imageCount = useAppSelector(selectImageCount); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); 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 ae1539a20a..430fdf9629 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx @@ -1,10 +1,8 @@ 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 { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectImageCount } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; @@ -13,9 +11,7 @@ import { PiArrowLeftBold } from 'react-icons/pi'; export const StagingAreaToolbarPrevButton = memo(() => { const ctx = useCanvasSessionContext(); const itemCount = useStore(ctx.$itemCount); - const dispatch = useAppDispatch(); const canvasManager = useCanvasManager(); - const imageCount = useAppSelector(selectImageCount); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx index b3f0e21972..641b62dd98 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderWarnings.tsx @@ -4,6 +4,7 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useEntityIsEnabled } from 'features/controlLayers/hooks/useEntityIsEnabled'; +import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { @@ -18,7 +19,6 @@ import { upperFirst } from 'lodash-es'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiWarningBold } from 'react-icons/pi'; -import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts index dfc4780d37..a1b0ba67c5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -15,7 +15,7 @@ import { rgNegativePromptChanged, rgPositivePromptChanged, } from 'features/controlLayers/store/canvasSlice'; -import { selectBase } from 'features/controlLayers/store/paramsSlice'; +import { selectBase, selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import type { CanvasEntityIdentifier, @@ -35,11 +35,7 @@ import { } from 'features/controlLayers/store/util'; import { zModelIdentifierField } from 'features/nodes/types/common'; import { useCallback } from 'react'; -import { - modelConfigsAdapterSelectors, - selectModelConfigsQuery, -} from 'services/api/endpoints/models'; -import { selectMainModelConfig } from '../store/paramsSlice'; +import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; import type { ControlLoRAModelConfig, ControlNetModelConfig, diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts index 5eb153cfc7..a1b2bac014 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts @@ -14,7 +14,12 @@ import { rgAdded, rgIPAdapterImageChanged, } from 'features/controlLayers/store/canvasSlice'; -import { selectNegativePrompt, selectPositivePrompt, selectSeed } from 'features/controlLayers/store/paramsSlice'; +import { + selectMainModelConfig, + selectNegativePrompt, + selectPositivePrompt, + selectSeed, +} from 'features/controlLayers/store/paramsSlice'; import { selectCanvasMetadata } from 'features/controlLayers/store/selectors'; import type { CanvasControlLayerState, @@ -33,7 +38,6 @@ import { toast } from 'features/toast/toast'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { serializeError } from 'serialize-error'; -import { selectMainModelConfig } from '../store/paramsSlice'; import type { ImageDTO } from 'services/api/types'; import type { JsonObject } from 'type-fest'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts deleted file mode 100644 index c36933a6cf..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasProgressImageModule.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Mutex } from 'async-mutex'; -import { parseify } from 'common/util/serialize'; -import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; -import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; -import { getPrefixedId, loadImage } from 'features/controlLayers/konva/util'; -import { selectShowProgressOnCanvas } from 'features/controlLayers/store/canvasSettingsSlice'; -import Konva from 'konva'; -import { atom } from 'nanostores'; -import type { Logger } from 'roarr'; -import { selectCanvasQueueCounts } from 'services/api/endpoints/queue'; -import type { S } from 'services/api/types'; -import type { SetNonNullable } from 'type-fest'; - -type ProgressEventWithImage = SetNonNullable; -const isProgressEventWithImage = (val: S['InvocationProgressEvent']): val is ProgressEventWithImage => - Boolean(val.image); - -export class CanvasProgressImageModule extends CanvasModuleBase { - readonly type = 'progress_image'; - readonly id: string; - readonly path: string[]; - readonly parent: CanvasManager; - readonly manager: CanvasManager; - readonly log: Logger; - - konva: { - group: Konva.Group; - image: Konva.Image | null; // The image is loaded asynchronously, so it may not be available immediately - }; - $isLoading = atom(false); - $isError = atom(false); - imageElement: HTMLImageElement | null = null; - - subscriptions = new Set<() => void>(); - $lastProgressEvent = atom(null); - $hasActiveGeneration = atom(false); - mutex: Mutex = new Mutex(); - - constructor(manager: CanvasManager) { - super(); - this.id = getPrefixedId(this.type); - this.parent = manager; - this.manager = manager; - this.path = this.manager.buildPath(this); - this.log = this.manager.buildLogger(this); - - this.log.debug('Creating progress image module'); - - this.konva = { - group: new Konva.Group({ name: `${this.type}:group`, listening: false }), - image: null, - }; - - this.subscriptions.add(this.manager.stagingArea.$shouldShowStagedImage.listen(this.render)); - this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectShowProgressOnCanvas, this.render)); - this.subscriptions.add(this.setSocketEventListeners()); - this.subscriptions.add( - this.manager.stateApi.createStoreSubscription(selectCanvasQueueCounts, ({ data }) => { - if (data && (data.in_progress > 0 || data.pending > 0)) { - this.$hasActiveGeneration.set(true); - } else { - this.$hasActiveGeneration.set(false); - } - }) - ); - this.subscriptions.add(this.$lastProgressEvent.listen(this.render)); - } - - setSocketEventListeners = (): (() => void) => { - const progressListener = (data: S['InvocationProgressEvent']) => { - if (data.destination !== 'canvas') { - return; - } - if (!isProgressEventWithImage(data)) { - return; - } - if (!this.$hasActiveGeneration.get()) { - return; - } - this.$lastProgressEvent.set(data); - }; - - // Handle a canceled or failed canvas generation. We should clear the progress image in this case. - const queueItemStatusChangedListener = (data: S['QueueItemStatusChangedEvent']) => { - if (data.destination !== 'canvas') { - return; - } - - // The staging area module handles _completed_ events. Only care about failed or canceled here. - if (data.status === 'failed' || data.status === 'canceled') { - this.$lastProgressEvent.set(null); - this.$hasActiveGeneration.set(false); - } - }; - - const clearProgress = () => { - this.$lastProgressEvent.set(null); - }; - - this.manager.socket.on('invocation_progress', progressListener); - this.manager.socket.on('queue_item_status_changed', queueItemStatusChangedListener); - this.manager.socket.on('connect', clearProgress); - this.manager.socket.on('connect_error', clearProgress); - this.manager.socket.on('disconnect', clearProgress); - - return () => { - this.manager.socket.off('invocation_progress', progressListener); - this.manager.socket.off('queue_item_status_changed', queueItemStatusChangedListener); - this.manager.socket.off('connect', clearProgress); - this.manager.socket.off('connect_error', clearProgress); - this.manager.socket.off('disconnect', clearProgress); - }; - }; - - getNodes = () => { - return [this.konva.group]; - }; - - render = async () => { - const release = await this.mutex.acquire(); - - const event = this.$lastProgressEvent.get(); - const showProgressOnCanvas = this.manager.stateApi.runSelector(selectShowProgressOnCanvas); - - if (!event || !showProgressOnCanvas) { - this.konva.group.visible(false); - this.konva.image?.destroy(); - this.konva.image = null; - this.imageElement = null; - this.$isLoading.set(false); - this.$isError.set(false); - release(); - return; - } - - this.$isLoading.set(true); - - const { x, y, width, height } = this.manager.stateApi.getBbox().rect; - try { - this.imageElement = await loadImage(event.image.dataURL); - if (this.konva.image) { - this.konva.image.setAttrs({ - image: this.imageElement, - x, - y, - width, - height, - }); - } else { - this.konva.image = new Konva.Image({ - name: `${this.type}:image`, - listening: false, - image: this.imageElement, - x, - y, - width, - height, - perfectDrawEnabled: false, - }); - this.konva.group.add(this.konva.image); - } - // Should not be visible if the user has disabled showing staging images - this.konva.group.visible(this.manager.stagingArea.$shouldShowStagedImage.get()); - } catch { - this.$isError.set(true); - } finally { - this.$isLoading.set(false); - release(); - } - }; - - destroy = () => { - this.log.debug('Destroying module'); - this.subscriptions.forEach((unsubscribe) => unsubscribe()); - this.subscriptions.clear(); - this.konva.group.destroy(); - }; - - repr = () => { - return { - id: this.id, - type: this.type, - path: this.path, - $lastProgressEvent: parseify(this.$lastProgressEvent.get()), - $hasActiveGeneration: this.$hasActiveGeneration.get(), - $isError: this.$isError.get(), - $isLoading: this.$isLoading.get(), - }; - }; -} diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 8768187044..83912c5b4d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -64,7 +64,6 @@ const zImageWithDimsDataURL = z.object({ width: z.number().int().positive(), height: z.number().int().positive(), }); -export type ImageWithDimsDataURL = z.infer; const zBeginEndStepPct = z .tuple([z.number().gte(0).lte(1), z.number().gte(0).lte(1)]) @@ -100,7 +99,7 @@ const zRgbColor = z.object({ b: z.number().int().min(0).max(255), }); export type RgbColor = z.infer; -export const zRgbaColor = zRgbColor.extend({ +const zRgbaColor = zRgbColor.extend({ a: z.number().min(0).max(1), }); export type RgbaColor = z.infer; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx deleted file mode 100644 index 82c4a52d14..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Skeleton } from '@invoke-ai/ui-library'; -import { memo } from 'react'; - -type Props = { - width: number; - height: number; -}; - -export const SizedSkeletonLoader = memo(({ width, height }: Props) => { - return ; -}); - -SizedSkeletonLoader.displayName = 'SizedSkeletonLoader'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 29b6a46374..b2f5158b7a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -2,7 +2,6 @@ import { Box, Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; -import { CanvasAlertsSendingToCanvas } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; @@ -52,7 +51,6 @@ const CurrentImagePreview = ({ imageDTO }: { imageDTO?: ImageDTO }) => { pointerEvents="none" alignItems="flex-start" > - {shouldShowImageDetails && imageDTO && ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 34d9329532..521c0a2486 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -7,7 +7,6 @@ import CurrentImagePreview from 'features/gallery/components/ImageViewer/Current import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison'; import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar'; import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; -import type { ReactNode } from 'react'; import { memo, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; @@ -16,25 +15,25 @@ import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { useImageViewer } from './useImageViewer'; -type Props = { - closeButton?: ReactNode; -}; +// type Props = { +// closeButton?: ReactNode; +// }; -const useFocusRegionOptions = { - focusOnMount: true, -}; +// const useFocusRegionOptions = { +// focusOnMount: true, +// }; -const FOCUS_REGION_STYLES: SystemStyleObject = { - display: 'flex', - width: 'full', - height: 'full', - position: 'absolute', - flexDirection: 'column', - inset: 0, - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', -}; +// const FOCUS_REGION_STYLES: SystemStyleObject = { +// display: 'flex', +// width: 'full', +// height: 'full', +// position: 'absolute', +// flexDirection: 'column', +// inset: 0, +// alignItems: 'center', +// justifyContent: 'center', +// overflow: 'hidden', +// }; export const ImageViewer = memo(() => { const lastSelectedImageName = useAppSelector(selectLastSelectedImageName); diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index c57954b2ec..7b1386b5ba 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -93,7 +93,7 @@ export const zMainModelBase = z.enum([ 'chatgpt-4o', 'flux-kontext', ]); -export type MainModelBase = z.infer; +type MainModelBase = z.infer; export const isMainModelBase = (base: unknown): base is MainModelBase => zMainModelBase.safeParse(base).success; const zModelType = z.enum([ 'main', diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts index ce87203eeb..2d0eb03d44 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts @@ -149,7 +149,7 @@ export const getInfill = ( assert(false, 'Unknown infill method'); }; -export const CANVAS_OUTPUT_PREFIX = 'canvas_output'; +const CANVAS_OUTPUT_PREFIX = 'canvas_output'; export const isMainModelWithoutUnet = (modelLoader: Invocation) => { return ( diff --git a/invokeai/frontend/web/src/features/queue/components/ClearQueueButton.tsx b/invokeai/frontend/web/src/features/queue/components/ClearQueueButton.tsx deleted file mode 100644 index 72fc05723e..0000000000 --- a/invokeai/frontend/web/src/features/queue/components/ClearQueueButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ButtonProps } from '@invoke-ai/ui-library'; -import { Button } from '@invoke-ai/ui-library'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiTrashSimpleFill } from 'react-icons/pi'; - -import { useClearQueueDialog } from './ClearQueueConfirmationAlertDialog'; - -type Props = ButtonProps; - -const ClearQueueButton = (props: Props) => { - const { t } = useTranslation(); - const clearQueue = useClearQueueDialog(); - - return ( - <> - - - ); -}; - -export default memo(ClearQueueButton); diff --git a/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx index d4306e9473..9de86f9eb7 100644 --- a/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; const [useClearQueueConfirmationAlertDialog] = buildUseBoolean(false); -export const useClearQueueDialog = () => { +const useClearQueueDialog = () => { const dialog = useClearQueueConfirmationAlertDialog(); const { clearQueue, isLoading, isDisabled, queueStatus } = useClearQueue(); diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index 83f220e4a6..199e5d8c43 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -1,22 +1,15 @@ -import { Box, Flex } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasMainPanelContent } from 'features/controlLayers/components/CanvasMainPanelContent'; import { useDndMonitor } from 'features/dnd/useDndMonitor'; -import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; -import WorkflowsTabLeftPanel from 'features/nodes/components/sidePanel/WorkflowsTabLeftPanel'; -import QueueControls from 'features/queue/components/QueueControls'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; -import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; +import { LeftPanelContent } from 'features/ui/components/LeftPanelContent'; +import { MainPanelContent } from 'features/ui/components/MainPanelContent'; import { RightPanelContent } from 'features/ui/components/RightPanelContent'; -import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab'; -import QueueTab from 'features/ui/components/tabs/QueueTab'; -import { WorkflowsMainPanel } from 'features/ui/components/tabs/WorkflowsTabContent'; import { VerticalNavBar } from 'features/ui/components/VerticalNavBar'; import type { UsePanelOptions } from 'features/ui/hooks/usePanel'; import { usePanel } from 'features/ui/hooks/usePanel'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { $isLeftPanelOpen, $isRightPanelOpen, @@ -29,10 +22,7 @@ import type { CSSProperties } from 'react'; import { memo, useMemo, useRef } from 'react'; import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels'; -import type { Equals } from 'tsafe'; -import { assert } from 'tsafe'; -import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale'; import { VerticalResizeHandle } from './tabs/ResizeHandle'; const panelStyles: CSSProperties = { position: 'relative', height: '100%', width: '100%', minWidth: 0 }; @@ -153,42 +143,3 @@ export const AppContent = memo(() => { ); }); AppContent.displayName = 'AppContent'; - -const LeftPanelContent = memo(() => { - const tab = useAppSelector(selectActiveTab); - - return ( - - - - {tab === 'canvas' && } - {tab === 'upscaling' && } - {tab === 'workflows' && } - - - ); -}); -LeftPanelContent.displayName = 'LeftPanelContent'; - -const MainPanelContent = memo(() => { - const tab = useAppSelector(selectActiveTab); - - if (tab === 'canvas') { - return ; - } - if (tab === 'upscaling') { - return ; - } - if (tab === 'workflows') { - return ; - } - if (tab === 'models') { - return ; - } - if (tab === 'queue') { - return ; - } - - assert>(false); -}); -MainPanelContent.displayName = 'MainPanelContent'; diff --git a/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx index 873e803186..f6190ed58c 100644 --- a/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx @@ -8,7 +8,7 @@ import { memo } from 'react'; import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale'; -const LeftPanelContent = memo(() => { +export const LeftPanelContent = memo(() => { const tab = useAppSelector(selectActiveTab); return ( diff --git a/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx index 58ac5a4558..48c812e6a1 100644 --- a/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx @@ -9,7 +9,7 @@ import { memo } from 'react'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; -const MainPanelContent = memo(() => { +export const MainPanelContent = memo(() => { const tab = useAppSelector(selectActiveTab); if (tab === 'canvas') { diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index 405c794582..bb526fab36 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -324,4 +324,3 @@ export const { } = modelsApi; export const selectModelConfigsQuery = modelsApi.endpoints.getModelConfigs.select(); - diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index 6a13f7e2e4..2239a5df3a 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -364,11 +364,8 @@ export const { useClearQueueMutation, usePruneQueueMutation, useGetQueueStatusQuery, - useGetQueueItemQuery, useListQueueItemsQuery, - useListAllQueueItemsQuery, useCancelQueueItemMutation, - useCancelByDestinationMutation, useDeleteQueueItemMutation, useDeleteQueueItemsByDestinationMutation, useGetBatchStatusQuery, diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index e26a2d05d7..8e299202e9 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -11,9 +11,7 @@ import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; -import { - $lastProgressEvent, -} from 'services/events/stores'; +import { $lastProgressEvent } from 'services/events/stores'; import type { Param0 } from 'tsafe'; import { objectEntries } from 'tsafe'; import type { JsonObject } from 'type-fest'; diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 72ec0feb95..a5b4c069c5 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -31,14 +31,11 @@ import type { Socket } from 'socket.io-client'; import type { JsonObject } from 'type-fest'; import { - $lastCanvasProgressEvent, - $lastCanvasProgressImage, $lastProgressEvent, $lastUpscalingProgressEvent, $lastUpscalingProgressImage, $lastWorkflowsProgressEvent, $lastWorkflowsProgressImage, - $progressImages, } from './stores'; const log = logger('events'); @@ -116,22 +113,6 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis $lastProgressEvent.set(data); - if (data.image) { - const progressData = $progressImages.get()[session_id]; - if (progressData) { - $progressImages.setKey(session_id, { ...progressData, progressImage: data.image }); - } else { - $progressImages.setKey(session_id, { sessionId: session_id, isFinished: false, progressImage: data.image }); - } - } - - if (origin === 'canvas') { - $lastCanvasProgressEvent.set(data); - if (image) { - $lastCanvasProgressImage.set({ sessionId: session_id, image }); - } - } - if (origin === 'upscaling') { $lastUpscalingProgressEvent.set(data); if (image) { diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index 0d62110efd..c86e147ea4 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -1,8 +1,7 @@ import type { EphemeralProgressImage } from 'features/controlLayers/store/types'; -import type { ProgressImage } from 'features/nodes/types/common'; import { round } from 'lodash-es'; import { atom, computed, map } from 'nanostores'; -import type { ImageDTO, S } from 'services/api/types'; +import type { S } from 'services/api/types'; import type { AppSocket } from 'services/events/types'; import type { ManagerOptions, SocketOptions } from 'socket.io-client'; @@ -11,22 +10,6 @@ export const $socketOptions = map>({}); export const $isConnected = atom(false); export const $lastProgressEvent = atom(null); -export type ProgressAndResult = { - sessionId: string; - isFinished: boolean; - progressImage?: ProgressImage; - resultImage?: ImageDTO; -}; -export const $progressImages = map({} as Record); - -export type ProgressData = { - sessionId: string; - progressEvent: S['InvocationProgressEvent'] | null; - progressImage: ProgressImage | null; -}; - -export const $lastCanvasProgressEvent = atom(null); -export const $lastCanvasProgressImage = atom(null); export const $lastWorkflowsProgressEvent = atom(null); export const $lastWorkflowsProgressImage = atom(null); export const $lastUpscalingProgressEvent = atom(null); From 3a08ea799a19a3ad2ab065009106e07cb2d7e2a6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 18:46:45 +1000 Subject: [PATCH 054/210] feat(ui): update canvas session state handling for new staging strat --- .../web/src/app/hooks/useStudioInitAction.ts | 8 +- .../middleware/listenerMiddleware/index.ts | 4 - .../addCommitStagingAreaImageListener.ts | 46 ------ .../listeners/enqueueRequestedLinear.ts | 14 +- .../AdvancedSession/AdvancedSession.tsx | 45 ++--- .../components/CanvasMainPanelContent.tsx | 23 +-- .../NewSessionConfirmationAlertDialog.tsx | 6 +- .../components/NoSession/NoSession.tsx | 4 +- .../SimpleSession/SimpleSession.tsx | 5 +- .../SimpleSession/StagingAreaHeader.tsx | 4 +- .../components/SimpleSession/context.tsx | 9 +- .../StagingAreaToolbarAcceptButton.tsx | 7 +- ...AreaToolbarSaveSelectedToGalleryButton.tsx | 4 +- .../konva/CanvasStateApiModule.ts | 4 +- .../controlLayers/store/canvasSlice.ts | 4 +- .../store/canvasStagingAreaSlice.ts | 154 +++--------------- .../controlLayers/store/lorasSlice.ts | 4 +- .../web/src/features/hrf/store/hrfSlice.ts | 4 +- .../web/src/features/imageActions/actions.ts | 14 +- .../nodes/util/graph/graphBuilderUtils.ts | 6 +- .../stylePresets/store/stylePresetSlice.ts | 4 +- .../components/FloatingLeftPanelButtons.tsx | 6 +- .../ui/components/RightPanelContent.tsx | 6 +- .../web/src/features/ui/store/uiSlice.ts | 4 +- 24 files changed, 117 insertions(+), 272 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index 34d2f8ea88..29db2ff0b3 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { withResultAsync } from 'common/util/result'; import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/util'; import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; @@ -90,7 +90,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => { const overrides: Partial = { objects: [imageObject], }; - store.dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + store.dispatch(canvasSessionTypeChanged({ type: 'advanced' })); store.dispatch(rasterLayerAdded({ overrides, isSelected: true })); store.dispatch(setActiveTab('canvas')); store.dispatch(sentImageToCanvas()); @@ -162,14 +162,14 @@ export const useStudioInitAction = (action?: StudioInitAction) => { switch (destination) { case 'generation': // Go to the canvas tab, open the image viewer, and enable send-to-gallery mode - store.dispatch(canvasSessionStarted({ sessionType: 'simple' })); + store.dispatch(canvasSessionTypeChanged({ type: 'simple' })); store.dispatch(setActiveTab('canvas')); store.dispatch(activeTabCanvasRightPanelChanged('gallery')); $imageViewer.set(true); break; case 'canvas': // Go to the canvas tab, close the image viewer, and disable send-to-gallery mode - store.dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + store.dispatch(canvasSessionTypeChanged({ type: 'advanced' })); store.dispatch(setActiveTab('canvas')); $imageViewer.set(false); break; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 2d8942f9e5..5db4acb243 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -1,7 +1,6 @@ import type { TypedStartListening } from '@reduxjs/toolkit'; import { addListener, createListenerMiddleware } from '@reduxjs/toolkit'; import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener'; -import { addStagingListeners } from 'app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener'; import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued'; import { addAppConfigReceivedListener } from 'app/store/middleware/listenerMiddleware/listeners/appConfigReceived'; import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted'; @@ -65,9 +64,6 @@ addEnqueueRequestedUpscale(startAppListening); addAnyEnqueuedListener(startAppListening); addBatchEnqueuedListener(startAppListening); -// Canvas actions -addStagingListeners(startAppListening); - // Socket.IO addSocketConnectedEventListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts deleted file mode 100644 index 6ef2af6c2f..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { isAnyOf } from '@reduxjs/toolkit'; -import { logger } from 'app/logging/logger'; -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { canvasReset } from 'features/controlLayers/store/actions'; -import { stagingAreaReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { toast } from 'features/toast/toast'; -import { t } from 'i18next'; -import { queueApi } from 'services/api/endpoints/queue'; - -const log = logger('canvas'); - -const matchCanvasOrStagingAreaReset = isAnyOf(stagingAreaReset, canvasReset); - -export const addStagingListeners = (startAppListening: AppStartListening) => { - startAppListening({ - matcher: matchCanvasOrStagingAreaReset, - effect: async (_, { dispatch }) => { - try { - const req = dispatch( - queueApi.endpoints.cancelByDestination.initiate( - { destination: 'canvas' }, - { fixedCacheKey: 'cancelByBatchOrigin' } - ) - ); - const { canceled } = await req.unwrap(); - req.reset(); - - if (canceled > 0) { - log.debug(`Canceled ${canceled} canvas batches`); - toast({ - id: 'CANCEL_BATCH_SUCCEEDED', - title: t('queue.cancelBatchSucceeded'), - status: 'success', - }); - } - } catch { - log.error('Failed to cancel canvas batches'); - toast({ - id: 'CANCEL_BATCH_FAILED', - title: t('queue.cancelBatchFailed'), - status: 'error', - }); - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts index 79413b94a0..8deabbe967 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -5,7 +5,10 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware' import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; import { withResult, withResultAsync } from 'common/util/result'; import { parseify } from 'common/util/serialize'; -import { canvasSessionStarted, selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { + canvasSessionGenerationStarted, + selectCanvasSessionId, +} from 'features/controlLayers/store/canvasStagingAreaSlice'; import { $canvasManager } from 'features/controlLayers/store/ephemeral'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildChatGPT4oGraph } from 'features/nodes/util/graph/generation/buildChatGPT4oGraph'; @@ -33,11 +36,14 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) effect: async (action, { getState, dispatch }) => { log.debug('Enqueue requested'); - if (!selectCanvasSession(getState())) { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); + if (!selectCanvasSessionId(getState())) { + dispatch(canvasSessionGenerationStarted()); } const state = getState(); + const destination = state.canvasSession.id; + assert(destination !== null); + const { prepend } = action.payload; const manager = $canvasManager.get(); @@ -96,8 +102,6 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) const { g, seedFieldIdentifier, positivePromptFieldIdentifier } = buildGraphResult.value; - const destination = state.canvasSession.session?.id ?? 'canvas'; - const prepareBatchResult = withResult(() => prepareLinearUIBatch({ state, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx index 47fd674016..0155f52c06 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx @@ -20,7 +20,6 @@ import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasT import { Transform } from 'features/controlLayers/components/Transform/Transform'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; -import type { AdvancedSessionIdentifier } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; @@ -53,7 +52,7 @@ const canvasBgSx = { }, }; -export const AdvancedSession = memo(({ session }: { session: AdvancedSessionIdentifier }) => { +export const AdvancedSession = memo(({ id }: { id: string | null }) => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); @@ -107,27 +106,29 @@ export const AdvancedSession = memo(({ session }: { session: AdvancedSessionIden )} - - - - - + {id !== null && ( + + + + + + + + + - - - - - - + + + )} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 98b7bf3a5b..242e3e363c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -2,26 +2,27 @@ import { useAppSelector } from 'app/store/storeHooks'; import { AdvancedSession } from 'features/controlLayers/components/AdvancedSession/AdvancedSession'; import { NoSession } from 'features/controlLayers/components/NoSession/NoSession'; import { SimpleSession } from 'features/controlLayers/components/SimpleSession/SimpleSession'; -import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectCanvasSessionId, selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo } from 'react'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; export const CanvasMainPanelContent = memo(() => { - const session = useAppSelector(selectCanvasSession); + const type = useAppSelector(selectCanvasSessionType); + const id = useAppSelector(selectCanvasSessionId); - if (session === null) { - return ; + if (type === 'simple') { + if (id === null) { + return ; + } else { + return ; + } } - if (session.type === 'simple') { - return ; + if (type === 'advanced') { + return ; } - if (session.type === 'advanced') { - return ; - } - - assert>(false, 'Unexpected session'); + assert>(false, 'Unexpected session type'); }); CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx index fd65ae8095..84466bf0cf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx @@ -2,7 +2,7 @@ import { Checkbox, ConfirmationAlertDialog, Flex, FormControl, FormLabel, Text } import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { buildUseBoolean } from 'common/hooks/useBoolean'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectSystemShouldConfirmOnNewSession, shouldConfirmOnNewSessionToggled, @@ -20,7 +20,7 @@ export const useNewGallerySession = () => { const newSessionDialog = useNewGallerySessionDialog(); const newGallerySessionImmediate = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); + dispatch(canvasSessionTypeChanged({ type: 'simple' })); dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); @@ -41,7 +41,7 @@ export const useNewCanvasSession = () => { const newSessionDialog = useNewCanvasSessionDialog(); const newCanvasSessionImmediate = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); dispatch(activeTabCanvasRightPanelChanged('layers')); }, [dispatch]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx index 4586b89573..607fcc5df8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx @@ -5,13 +5,13 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { GenerateWithControlImage } from 'features/controlLayers/components/NoSession/GenerateWithControlImage'; import { GenerateWithStartingImage } from 'features/controlLayers/components/NoSession/GenerateWithStartingImage'; import { GenerateWithStartingImageAndInpaintMask } from 'features/controlLayers/components/NoSession/GenerateWithStartingImageAndInpaintMask'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; export const NoSession = memo(() => { const dispatch = useAppDispatch(); const newSesh = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); }, [dispatch]); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx index 2b543c6361..621bf95141 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx @@ -1,11 +1,10 @@ import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context'; import { StagingArea } from 'features/controlLayers/components/SimpleSession/StagingArea'; -import type { SimpleSessionIdentifier } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo } from 'react'; -export const SimpleSession = memo(({ session }: { session: SimpleSessionIdentifier }) => { +export const SimpleSession = memo(({ id }: { id: string }) => { return ( - + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx index 7e8963c298..4e265a5e18 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -3,7 +3,7 @@ import { Button, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@inv import { useStore } from '@nanostores/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ChangeEvent } from 'react'; import { memo, useCallback } from 'react'; @@ -13,7 +13,7 @@ export const StagingAreaHeader = memo(() => { const dispatch = useAppDispatch(); const startOver = useCallback(() => { - dispatch(canvasSessionStarted({ sessionType: 'simple' })); + dispatch(canvasSessionTypeChanged({ type: 'simple' })); }, [dispatch]); const onChangeAutoSwitch = useCallback( 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 d4089d761d..cd4758c56b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -3,10 +3,6 @@ import { createSelector } from '@reduxjs/toolkit'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; -import type { - AdvancedSessionIdentifier, - SimpleSessionIdentifier, -} from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ProgressImage } from 'features/nodes/types/common'; import type { Atom, WritableAtom } from 'nanostores'; import { atom, computed, effect } from 'nanostores'; @@ -100,7 +96,7 @@ export const clearProgressImage = ($progressData: WritableAtom; $itemCount: Atom; $hasItems: Atom; @@ -120,12 +116,13 @@ type CanvasSessionContextValue = { const CanvasSessionContext = createContext(null); export const CanvasSessionContextProvider = memo( - ({ session, children }: PropsWithChildren<{ session: SimpleSessionIdentifier | AdvancedSessionIdentifier }>) => { + ({ id, type, children }: PropsWithChildren<{ id: string; type: 'simple' | 'advanced' }>) => { /** * For best performance and interop with the Canvas, which is outside react but needs to interact with the react * app, all canvas session state is packaged as nanostores atoms. The trickiest part is syncing the queue items * with a nanostores atom. */ + const session = useMemo(() => ({ type, id }), [type, id]); /** * App store 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 147b473af7..692aa774e2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -5,7 +5,6 @@ import { useIsRegionFocused } from 'common/hooks/focus'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; -import { selectImageCount, stagingAreaReset } 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'; @@ -20,7 +19,6 @@ export const StagingAreaToolbarAcceptButton = memo(() => { const canvasManager = useCanvasManager(); const bboxRect = useAppSelector(selectBboxRect); const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); - const imageCount = useAppSelector(selectImageCount); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); const selectedItemImageName = useStore(ctx.$selectedItemOutputImageName); @@ -39,7 +37,6 @@ export const StagingAreaToolbarAcceptButton = memo(() => { }; dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' })); - dispatch(stagingAreaReset()); }, [bboxRect, selectedItemImageName, dispatch, selectedEntityIdentifier?.type]); useHotkeys( @@ -47,9 +44,9 @@ export const StagingAreaToolbarAcceptButton = memo(() => { acceptSelected, { preventDefault: true, - enabled: isCanvasFocused && shouldShowStagedImage && imageCount > 1, + enabled: isCanvasFocused && shouldShowStagedImage && selectedItemImageName !== null, }, - [isCanvasFocused, shouldShowStagedImage, imageCount] + [isCanvasFocused, shouldShowStagedImage, selectedItemImageName] ); return ( 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 ce5f5707b1..4301183fe8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx @@ -3,7 +3,6 @@ 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 { selectSelectedImage } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; @@ -15,7 +14,6 @@ const TOAST_ID = 'SAVE_STAGING_AREA_IMAGE_TO_GALLERY'; export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { const autoAddBoardId = useAppSelector(selectAutoAddBoardId); - const selectedImage = useAppSelector(selectSelectedImage); const ctx = useCanvasSessionContext(); const imageName = useStore(ctx.$selectedItemOutputImageName); @@ -63,7 +61,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { icon={} onClick={saveSelectedImageToGallery} colorScheme="invokeBlue" - isDisabled={!selectedImage} + isDisabled={!imageName} /> ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index f517d96b02..60bfd2f309 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -29,7 +29,7 @@ import { rasterLayerAdded, rgAdded, } from 'features/controlLayers/store/canvasSlice'; -import { selectCanvasStagingAreaSlice } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectCanvasSessionSlice } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectAllRenderableEntities, selectBbox, @@ -537,7 +537,7 @@ export class CanvasStateApiModule extends CanvasModuleBase { * Gets the canvas staging area state from redux. */ getStagingArea = () => { - return this.runSelector(selectCanvasStagingAreaSlice); + return this.runSelector(selectCanvasSessionSlice); }; /** diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index d3181c7e9c..cda6f3742b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -6,7 +6,7 @@ import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { modelChanged } from 'features/controlLayers/store/paramsSlice'; import { selectAllEntities, @@ -1846,7 +1846,7 @@ export const canvasSlice = createSlice({ syncScaledSize(state); } }); - builder.addCase(canvasSessionStarted, (state) => resetState(state)); + builder.addCase(canvasSessionReset, (state) => resetState(state)); }, }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index 4a403b6baf..bbaabff71a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -3,31 +3,15 @@ import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; -import type { StagingAreaImage, StagingAreaProgressImage } from 'features/controlLayers/store/types'; -import { selectCanvasQueueCounts } from 'services/api/endpoints/queue'; - -export type SimpleSessionIdentifier = { - type: 'simple'; - id: string; -}; - -export type AdvancedSessionIdentifier = { - type: 'advanced'; - id: string; -}; type CanvasStagingAreaState = { - session: SimpleSessionIdentifier | AdvancedSessionIdentifier | null; - sessionType: 'simple' | 'advanced' | null; - images: (StagingAreaImage | StagingAreaProgressImage)[]; - selectedImageIndex: number; + type: 'simple' | 'advanced'; + id: string | null; }; const INITIAL_STATE: CanvasStagingAreaState = { - session: null, - sessionType: null, - images: [], - selectedImageIndex: 0, + type: 'simple', + id: null, }; const getInitialState = (): CanvasStagingAreaState => deepClone(INITIAL_STATE); @@ -36,68 +20,24 @@ export const canvasSessionSlice = createSlice({ name: 'canvasSession', initialState: getInitialState(), reducers: { - sessionChanged: (state, action: PayloadAction<{ session: CanvasStagingAreaState['session'] }>) => { - const { session } = action.payload; - state.session = session; + canvasSessionTypeChanged: (state, action: PayloadAction<{ type: CanvasStagingAreaState['type'] }>) => { + const { type } = action.payload; + state.type = type; + state.id = null; }, - stagingAreaImageStaged: (state, action: PayloadAction<{ stagingAreaImage: StagingAreaImage }>) => { - const { stagingAreaImage } = action.payload; - let didReplace = false; - const newImages = []; - for (const i of state.images) { - if (i.sessionId === stagingAreaImage.sessionId) { - newImages.push(stagingAreaImage); - didReplace = true; - } else { - newImages.push(i); - } - } - if (!didReplace) { - newImages.push(stagingAreaImage); - } - state.images = newImages; - }, - stagingAreaGenerationStarted: (state, action: PayloadAction<{ sessionId: string }>) => { - const { sessionId } = action.payload; - state.images.push({ type: 'progress', sessionId }); - }, - stagingAreaGenerationFinished: (state, action: PayloadAction<{ sessionId: string }>) => { - const { sessionId } = action.payload; - state.images = state.images.filter((data) => data.sessionId !== sessionId); - }, - stagingAreaImageSelected: (state, action: PayloadAction<{ index: number }>) => { - const { index } = action.payload; - state.selectedImageIndex = index; - }, - stagingAreaNextStagedImageSelected: (state) => { - state.selectedImageIndex = (state.selectedImageIndex + 1) % state.images.length; - }, - stagingAreaPrevStagedImageSelected: (state) => { - state.selectedImageIndex = (state.selectedImageIndex - 1 + state.images.length) % state.images.length; - }, - stagingAreaStagedImageDiscarded: (state, action: PayloadAction<{ index: number }>) => { - const { index } = action.payload; - state.images.splice(index, 1); - state.selectedImageIndex = Math.min(state.selectedImageIndex, state.images.length - 1); - }, - stagingAreaReset: (state) => { - state.images = []; - state.selectedImageIndex = 0; - }, - canvasSessionStarted: { - reducer: (state, action: PayloadAction<{ session: CanvasStagingAreaState['session'] }>) => { - const { session } = action.payload; - state.session = session; + canvasSessionGenerationStarted: { + reducer: (state, action: PayloadAction<{ id: string }>) => { + const { id } = action.payload; + state.id = id; }, - prepare: (payload: { sessionType: 'simple' | 'advanced' }) => ({ - payload: { - session: { - type: payload.sessionType, - id: getPrefixedId(`canvas:${payload.sessionType}`), - }, - }, + prepare: () => ({ + payload: { id: getPrefixedId('canvas') }, }), }, + canvasSessionGenerationFinished: (state) => { + state.id = null; + }, + canvasSessionReset: () => getInitialState(), }, extraReducers(builder) { builder.addCase(canvasReset, () => getInitialState()); @@ -105,16 +45,10 @@ export const canvasSessionSlice = createSlice({ }); export const { - sessionChanged, - stagingAreaImageStaged, - stagingAreaGenerationStarted, - stagingAreaGenerationFinished, - stagingAreaStagedImageDiscarded, - stagingAreaReset, - stagingAreaImageSelected, - stagingAreaNextStagedImageSelected, - stagingAreaPrevStagedImageSelected, - canvasSessionStarted, + canvasSessionTypeChanged, + canvasSessionGenerationStarted, + canvasSessionReset, + canvasSessionGenerationFinished, } = canvasSessionSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -129,44 +63,8 @@ export const canvasStagingAreaPersistConfig: PersistConfig s[canvasSessionSlice.name]; +export const selectCanvasSessionSlice = (s: RootState) => s[canvasSessionSlice.name]; -/** - * Selects if we should be staging images. This is true if: - * - There are staged images. - * - There are any in-progress or pending canvas queue items. - */ -export const selectIsStaging = createSelector( - selectCanvasQueueCounts, - selectCanvasStagingAreaSlice, - ({ data }, staging) => { - if (staging.images.length > 0) { - return true; - } - if (!data) { - return false; - } - return data.in_progress > 0 || data.pending > 0; - } -); -export const selectStagedImageIndex = createSelector( - selectCanvasStagingAreaSlice, - (stagingArea) => stagingArea.selectedImageIndex -); -export const selectSelectedImage = createSelector( - [selectCanvasStagingAreaSlice, selectStagedImageIndex], - (stagingArea, index) => stagingArea.images[index] ?? null -); -export const selectStagedImages = createSelector(selectCanvasStagingAreaSlice, (stagingArea) => stagingArea.images); -export const selectImageCount = createSelector( - selectCanvasStagingAreaSlice, - (stagingArea) => stagingArea.images.length -); -export const selectCanvasSessionType = createSelector( - selectCanvasStagingAreaSlice, - (canvasSession) => canvasSession.sessionType -); -export const selectCanvasSession = createSelector( - selectCanvasStagingAreaSlice, - (canvasSession) => canvasSession.session -); +export const selectIsStaging = createSelector(selectCanvasSessionSlice, ({ id }) => id !== null); +export const selectCanvasSessionType = createSelector(selectCanvasSessionSlice, ({ type }) => type); +export const selectCanvasSessionId = createSelector(selectCanvasSessionSlice, ({ id }) => id); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts index e4d91cc2f2..ea34668a84 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts @@ -1,7 +1,7 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { LoRA } from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import type { LoRAModelConfig } from 'services/api/types'; @@ -64,7 +64,7 @@ export const lorasSlice = createSlice({ }, }, extraReducers(builder) { - builder.addCase(canvasSessionStarted, () => { + builder.addCase(canvasSessionTypeChanged, () => { // When a new session is requested, clear all LoRAs return deepClone(initialState); }); diff --git a/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts b/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts index c9499ad613..0bfe797ad1 100644 --- a/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts +++ b/invokeai/frontend/web/src/features/hrf/store/hrfSlice.ts @@ -2,7 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { ParameterHRFMethod, ParameterStrength } from 'features/parameters/types/parameterSchemas'; interface HRFState { @@ -34,7 +34,7 @@ export const hrfSlice = createSlice({ }, }, extraReducers(builder) { - builder.addCase(canvasSessionStarted, () => { + builder.addCase(canvasSessionTypeChanged, () => { return deepClone(initialHRFState); }); }, diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index d432433903..b73577bbdf 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -15,7 +15,7 @@ import { rgAdded, rgIPAdapterImageChanged, } from 'features/controlLayers/store/canvasSlice'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectBboxModelBase, selectBboxRect } from 'features/controlLayers/store/selectors'; import type { CanvasControlLayerState, @@ -194,7 +194,7 @@ export const newCanvasFromImage = async (arg: { objects: [imageObject], } satisfies Partial; addFitOnLayerInitCallback(overrides.id); - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); // The `bboxChangedFromCanvas` reducer does no validation! Careful! dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(rasterLayerAdded({ overrides, isSelected: true })); @@ -211,7 +211,7 @@ export const newCanvasFromImage = async (arg: { controlAdapter: deepClone(initialControlNet), } satisfies Partial; addFitOnLayerInitCallback(overrides.id); - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); // The `bboxChangedFromCanvas` reducer does no validation! Careful! dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(controlLayerAdded({ overrides, isSelected: true })); @@ -227,7 +227,7 @@ export const newCanvasFromImage = async (arg: { objects: [imageObject], } satisfies Partial; addFitOnLayerInitCallback(overrides.id); - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); // The `bboxChangedFromCanvas` reducer does no validation! Careful! dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(inpaintMaskAdded({ overrides, isSelected: true })); @@ -243,7 +243,7 @@ export const newCanvasFromImage = async (arg: { objects: [imageObject], } satisfies Partial; addFitOnLayerInitCallback(overrides.id); - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); // The `bboxChangedFromCanvas` reducer does no validation! Careful! dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); dispatch(rgAdded({ overrides, isSelected: true })); @@ -256,7 +256,7 @@ export const newCanvasFromImage = async (arg: { case 'reference_image': { const ipAdapter = deepClone(selectDefaultRefImageConfig(getState())); ipAdapter.image = imageDTOToImageWithDims(imageDTO); - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true })); if (withInpaintMask) { dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); @@ -268,7 +268,7 @@ export const newCanvasFromImage = async (arg: { const ipAdapter = deepClone(selectDefaultIPAdapter(getState())); ipAdapter.image = imageDTOToImageWithDims(imageDTO); const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }]; - dispatch(canvasSessionStarted({ sessionType: 'advanced' })); + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true })); if (withInpaintMask) { dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts index 2d0eb03d44..a34b7b95b2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts @@ -36,9 +36,9 @@ export const getBoardField = (state: RootState): BoardField | undefined => { export const selectCanvasOutputFields = (state: RootState) => { // Advanced session means working on canvas - images are not saved to gallery or added to a board. // Simple session means working in YOLO mode - images are saved to gallery & board. - const sessionType = selectCanvasSessionType(state); - const is_intermediate = sessionType === 'advanced'; - const board = sessionType === 'advanced' ? undefined : getBoardField(state); + const type = selectCanvasSessionType(state); + const is_intermediate = type === 'advanced'; + const board = type === 'advanced' ? undefined : getBoardField(state); return { is_intermediate, diff --git a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts index efee56c9fe..11a775c26d 100644 --- a/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts +++ b/invokeai/frontend/web/src/features/stylePresets/store/stylePresetSlice.ts @@ -2,7 +2,7 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionGenerationStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { atom } from 'nanostores'; import { stylePresetsApi } from 'services/api/endpoints/stylePresets'; @@ -29,7 +29,7 @@ export const stylePresetSlice = createSlice({ }, }, extraReducers(builder) { - builder.addCase(canvasSessionStarted, () => { + builder.addCase(canvasSessionGenerationStarted, () => { return deepClone(initialState); }); builder.addMatcher(stylePresetsApi.endpoints.deleteStylePreset.matchFulfilled, (state, action) => { diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx index f80a956319..973fb1de87 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx @@ -2,7 +2,7 @@ import { ButtonGroup, Flex, Icon, IconButton, spinAnimation, Tooltip, useShiftMo import { useAppSelector } from 'app/store/storeHooks'; import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip'; import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; @@ -22,11 +22,11 @@ import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; export const FloatingLeftPanelButtons = memo((props: { onToggle: () => void }) => { const tab = useAppSelector(selectActiveTab); - const session = useAppSelector(selectCanvasSession); + const type = useAppSelector(selectCanvasSessionType); return ( - {tab === 'canvas' && session?.type === 'advanced' && ( + {tab === 'canvas' && type === 'advanced' && ( diff --git a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx index 049e52f901..80d3ed935f 100644 --- a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx @@ -4,7 +4,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectCanvasSession } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { BoardsListPanelContent } from 'features/gallery/components/BoardsListPanelContent'; import { Gallery } from 'features/gallery/components/Gallery'; import { GalleryTopBar } from 'features/gallery/components/GalleryTopBar'; @@ -28,7 +28,7 @@ export const RightPanelContent = memo(() => { const boardSearchText = useAppSelector(selectBoardSearchText); const boardSearchDisclosure = useDisclosure({ defaultIsOpen: !!boardSearchText.length }); const imperativePanelGroupRef = useRef(null); - const session = useAppSelector(selectCanvasSession); + const type = useAppSelector(selectCanvasSessionType); const boardsListPanelOptions = useMemo( () => ({ @@ -77,7 +77,7 @@ export const RightPanelContent = memo(() => { - {session?.type === 'advanced' && ( + {type === 'advanced' && ( <> diff --git a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts index 3c4bf462bc..aa4b3ec0e0 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts @@ -1,7 +1,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { canvasSessionStarted } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { Dimensions } from 'features/controlLayers/store/types'; import { workflowLoaded } from 'features/nodes/store/nodesSlice'; import { atom } from 'nanostores'; @@ -56,7 +56,7 @@ export const uiSlice = createSlice({ builder.addCase(workflowLoaded, (state) => { state.activeTab = 'workflows'; }); - builder.addCase(canvasSessionStarted, (state) => { + builder.addCase(canvasSessionTypeChanged, (state) => { state.activeTab = 'canvas'; }); }, From 6570c0c3b90cafabc9a12da8564419e51f097abd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:15:04 +1000 Subject: [PATCH 055/210] feat(ui): more staging fixes --- .../SimpleSession/QueueItemPreviewMini.tsx | 8 +++-- .../SimpleSession/StagingAreaItemsList.tsx | 8 ++--- .../components/SimpleSession/context.tsx | 4 +-- .../StagingAreaToolbarAcceptButton.tsx | 9 +++-- .../StagingAreaToolbarDiscardAllButton.tsx | 11 +++++- ...tagingAreaToolbarDiscardSelectedButton.tsx | 5 ++- .../StagingAreaToolbarSaveAsMenu.tsx | 29 ++++++++++++--- ...AreaToolbarSaveSelectedToGalleryButton.tsx | 5 ++- ...gingAreaToolbarToggleShowResultsButton.tsx | 4 +-- .../konva/CanvasStagingAreaModule.ts | 35 +++++++++++++++---- 10 files changed, 91 insertions(+), 27 deletions(-) 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 acb907325b..3427f0cb9b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -18,7 +18,7 @@ const sx = { h: 108, w: 108, flexShrink: 0, - borderWidth: 1, + borderWidth: 2, borderRadius: 'base', '&[data-selected="true"]': { borderColor: 'invokeBlue.300', @@ -46,8 +46,10 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = const onLoad = useCallback(() => { setImageLoaded(true); - ctx.$lastLoadedItemId.set(item.item_id); - }, [ctx.$lastLoadedItemId, item.item_id]); + if (ctx.$progressData.get()[item.item_id]) { + ctx.$lastLoadedItemId.set(item.item_id); + } + }, [ctx.$lastLoadedItemId, ctx.$progressData, item.item_id]); return ( { return effect([ctx.$selectedItem, ctx.$progressData], (selectedItem, progressData) => { if (!selectedItem) { - canvasManager.stagingArea.render(); + canvasManager.stagingArea.$imageSrc.set(null); return; } const outputImageName = getOutputImageName(selectedItem); if (outputImageName) { - canvasManager.stagingArea.render({ type: 'imageName', data: outputImageName }); + canvasManager.stagingArea.$imageSrc.set({ type: 'imageName', data: outputImageName }); return; } const data = progressData[selectedItem.item_id]; if (data?.progressImage) { - canvasManager.stagingArea.render({ type: 'dataURL', data: data.progressImage.dataURL }); + canvasManager.stagingArea.$imageSrc.set({ type: 'dataURL', data: data.progressImage.dataURL }); return; } - canvasManager.stagingArea.render(); + canvasManager.stagingArea.$imageSrc.set(null); }); }, [canvasManager, ctx.$progressData, ctx.$selectedItem]); 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 cd4758c56b..5f1c73fdb0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -67,7 +67,7 @@ const setProgress = ($progressData: WritableAtom>, } }; -export const clearProgressEvent = ($progressData: WritableAtom>, itemId: number) => { +const clearProgressEvent = ($progressData: WritableAtom>, itemId: number) => { const progressData = $progressData.get(); const current = progressData[itemId]; if (!current) { @@ -81,7 +81,7 @@ export const clearProgressEvent = ($progressData: WritableAtom>, itemId: number) => { +const clearProgressImage = ($progressData: WritableAtom>, itemId: number) => { const progressData = $progressData.get(); const current = progressData[itemId]; if (!current) { 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 692aa774e2..b7280327eb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -5,6 +5,7 @@ import { useIsRegionFocused } from 'common/hooks/focus'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; +import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { imageNameToImageObject } from 'features/controlLayers/store/util'; @@ -12,6 +13,7 @@ import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiCheckBold } from 'react-icons/pi'; +import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarAcceptButton = memo(() => { const ctx = useCanvasSessionContext(); @@ -22,6 +24,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => { const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); const selectedItemImageName = useStore(ctx.$selectedItemOutputImageName); + const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation(); const { t } = useTranslation(); @@ -37,7 +40,9 @@ export const StagingAreaToolbarAcceptButton = memo(() => { }; dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' })); - }, [bboxRect, selectedItemImageName, dispatch, selectedEntityIdentifier?.type]); + dispatch(canvasSessionGenerationFinished()); + deleteByDestination({ destination: ctx.session.id }); + }, [selectedItemImageName, bboxRect, dispatch, selectedEntityIdentifier?.type, deleteByDestination, ctx.session.id]); useHotkeys( ['enter'], @@ -56,7 +61,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => { icon={} onClick={acceptSelected} colorScheme="invokeBlue" - isDisabled={!selectedItemImageName} + isDisabled={!selectedItemImageName || !shouldShowStagedImage} /> ); }); 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 dbc978a42a..99514b2b96 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx @@ -1,18 +1,26 @@ import { IconButton } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarDiscardAllButton = memo(() => { + const canvasManager = useCanvasManager(); const ctx = useCanvasSessionContext(); + const dispatch = useAppDispatch(); const { t } = useTranslation(); const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const discardAll = useCallback(() => { deleteByDestination({ destination: ctx.session.id }); - }, [deleteByDestination, ctx.session.id]); + dispatch(canvasSessionGenerationFinished()); + }, [deleteByDestination, ctx.session.id, dispatch]); return ( { onClick={discardAll} colorScheme="error" fontSize={16} + isDisabled={!shouldShowStagedImage} /> ); }); 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 c577bc4fa6..f6608672b5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -1,15 +1,18 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarDiscardSelectedButton = memo(() => { + const canvasManager = useCanvasManager(); const ctx = useCanvasSessionContext(); const [deleteQueueItem] = useDeleteQueueItemMutation(); const selectedItemId = useStore(ctx.$selectedItemId); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const { t } = useTranslation(); @@ -28,7 +31,7 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(() => { onClick={discardSelected} colorScheme="invokeBlue" fontSize={16} - isDisabled={selectedItemId === null} + isDisabled={selectedItemId === null || !shouldShowStagedImage} /> ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu.tsx index fe37c536e6..455eb97319 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu.tsx @@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/nanostores/store'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { createNewCanvasEntityFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; @@ -13,10 +14,12 @@ import { copyImage } from 'services/api/endpoints/images'; const uploadImageArg = { image_category: 'general', is_intermediate: true, silent: true } as const; export const StagingAreaToolbarSaveAsMenu = memo(() => { + const canvasManager = useCanvasManager(); const { t } = useTranslation(); const ctx = useCanvasSessionContext(); const imageName = useStore(ctx.$selectedItemOutputImageName); const store = useAppStore(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const toastSentToCanvas = useCallback(() => { toast({ @@ -101,19 +104,35 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => { tooltip={t('controlLayers.newLayerFromImage')} icon={} colorScheme="invokeBlue" - isDisabled={!imageName} + isDisabled={!imageName || !shouldShowStagedImage} /> - } onClickCapture={onClickNewInpaintMaskFromImage} isDisabled={!imageName}> + } + onClickCapture={onClickNewInpaintMaskFromImage} + isDisabled={!imageName || !shouldShowStagedImage} + > {t('controlLayers.inpaintMask')} - } onClickCapture={onClickNewRegionalGuidanceFromImage} isDisabled={!imageName}> + } + onClickCapture={onClickNewRegionalGuidanceFromImage} + isDisabled={!imageName || !shouldShowStagedImage} + > {t('controlLayers.regionalGuidance')} - } onClickCapture={onClickNewControlLayerFromImage} isDisabled={!imageName}> + } + onClickCapture={onClickNewControlLayerFromImage} + isDisabled={!imageName || !shouldShowStagedImage} + > {t('controlLayers.controlLayer')} - } onClickCapture={onClickNewRasterLayerFromImage} isDisabled={!imageName}> + } + onClickCapture={onClickNewRasterLayerFromImage} + isDisabled={!imageName || !shouldShowStagedImage} + > {t('controlLayers.rasterLayer')} 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 4301183fe8..7f7f6bea37 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx @@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { withResultAsync } from 'common/util/result'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; @@ -13,9 +14,11 @@ import { copyImage } from 'services/api/endpoints/images'; const TOAST_ID = 'SAVE_STAGING_AREA_IMAGE_TO_GALLERY'; export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { + const canvasManager = useCanvasManager(); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const ctx = useCanvasSessionContext(); const imageName = useStore(ctx.$selectedItemOutputImageName); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const { t } = useTranslation(); @@ -61,7 +64,7 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { icon={} onClick={saveSelectedImageToGallery} colorScheme="invokeBlue" - isDisabled={!imageName} + isDisabled={!imageName || !shouldShowStagedImage} /> ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton.tsx index ce1b3227fd..1bc40f3fe4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton.tsx @@ -12,8 +12,8 @@ export const StagingAreaToolbarToggleShowResultsButton = memo(() => { const { t } = useTranslation(); const toggleShowResults = useCallback(() => { - canvasManager.stagingArea.$shouldShowStagedImage.set(!shouldShowStagedImage); - }, [canvasManager.stagingArea.$shouldShowStagedImage, shouldShowStagedImage]); + canvasManager.stagingArea.$shouldShowStagedImage.set(!canvasManager.stagingArea.$shouldShowStagedImage.get()); + }, [canvasManager.stagingArea.$shouldShowStagedImage]); return ( (null); + $shouldShowStagedImage = atom(true); - $isStaging = atom(true); //TODO: wire up to queue? + $isStaging = atom(false); constructor(manager: CanvasManager) { super(); @@ -40,9 +43,26 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { /** * When we change this flag, we need to re-render the staging area, which hides or shows the staged image. */ + this.subscriptions.add(this.$shouldShowStagedImage.listen(this.render)); + + /** + * Rerender when the image source changes. + */ + this.subscriptions.add(this.$imageSrc.listen(this.render)); + + /** + * Sync the $isStaging flag with the redux state. $isStaging is used by the manager to determine the global busy + * state of the canvas. + * + * We also set the $shouldShowStagedImage flag when we enter staging mode, so that the staged images are shown, + * even if the user disabled this in the last staging session. + */ this.subscriptions.add( - this.$shouldShowStagedImage.listen(() => { - this.render(); + this.manager.stateApi.createStoreSubscription(selectIsStaging, (isStaging, oldIsStaging) => { + this.$isStaging.set(isStaging); + if (isStaging && !oldIsStaging) { + this.$shouldShowStagedImage.set(true); + } }) ); } @@ -50,6 +70,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { initialize = () => { this.log.debug('Initializing module'); this.render(); + this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging)); }; getImageFromSrc = ( @@ -72,7 +93,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { } }; - render = async (imageSrc?: { type: 'imageName'; data: string } | { type: 'dataURL'; data: string }) => { + render = async () => { const release = await this.mutex.acquire(); try { this.log.trace('Rendering staging area'); @@ -82,6 +103,8 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { this.konva.group.position({ x, y }); + const imageSrc = this.$imageSrc.get(); + if (imageSrc) { const image = this.getImageFromSrc(imageSrc, width, height); if (!this.image) { @@ -91,13 +114,13 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { } else if (this.image.isLoading || this.image.isError) { // noop } else { - await this.image.update({ ...this.image.state, image }, true); + await this.image.update({ ...this.image.state, image }); } - this.image.konva.group.visible(shouldShowStagedImage); } else { this.image?.destroy(); this.image = null; } + this.konva.group.visible(shouldShowStagedImage); } finally { release(); } From 1412c079ad1caad4bb9752449c1c5369702d5b73 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:29:23 +1000 Subject: [PATCH 056/210] feat(ui): improved staging placeholders --- .../SimpleSession/QueueItemPreviewMini.tsx | 1 + .../SimpleSession/QueueItemProgressImage.tsx | 9 +-- .../konva/CanvasStagingAreaModule.ts | 78 +++++++++++++++++-- 3 files changed, 74 insertions(+), 14 deletions(-) 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 3427f0cb9b..9a902b0ab7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -11,6 +11,7 @@ import { memo, useCallback, useState } from 'react'; import type { S } from 'services/api/types'; const sx = { + cursor: 'pointer', userSelect: 'none', pos: 'relative', alignItems: 'center', 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 c21e41e12a..64c9924c27 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -1,8 +1,7 @@ import type { ImageProps } from '@invoke-ai/ui-library'; -import { Flex, Icon, Image } from '@invoke-ai/ui-library'; +import { Flex, Image } from '@invoke-ai/ui-library'; import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { memo } from 'react'; -import { PiImageBold } from 'react-icons/pi'; type Props = { itemId: number } & ImageProps; @@ -11,11 +10,7 @@ export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => { const { progressImage } = useProgressData(ctx.$progressData, itemId); if (!progressImage) { - return ( - - - - ); + return ; } return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index 0d31f282cf..52cbc54611 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts @@ -9,6 +9,9 @@ import Konva from 'konva'; import { atom } from 'nanostores'; import type { Logger } from 'roarr'; +type ImageNameSrc = { type: 'imageName'; data: string }; +type DataURLSrc = { type: 'dataURL'; data: string }; + export class CanvasStagingAreaModule extends CanvasModuleBase { readonly type = 'staging_area'; readonly id: string; @@ -18,11 +21,18 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { readonly log: Logger; subscriptions: Set<() => void> = new Set(); - konva: { group: Konva.Group }; + konva: { + group: Konva.Group; + placeholder: { + group: Konva.Group; + rect: Konva.Rect; + text: Konva.Text; + }; + }; image: CanvasObjectImage | null; mutex = new Mutex(); - $imageSrc = atom<{ type: 'imageName'; data: string } | { type: 'dataURL'; data: string } | null>(null); + $imageSrc = atom(null); $shouldShowStagedImage = atom(true); $isStaging = atom(false); @@ -37,7 +47,48 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { this.log.debug('Creating module'); - this.konva = { group: new Konva.Group({ name: `${this.type}:group`, listening: false }) }; + const { width, height } = this.manager.stateApi.getBbox().rect; + + this.konva = { + group: new Konva.Group({ + name: `${this.type}:group`, + listening: false, + }), + placeholder: { + group: new Konva.Group({ + name: `${this.type}:placeholder_group`, + listening: false, + visible: false, + }), + rect: new Konva.Rect({ + name: `${this.type}:placeholder_rect`, + fill: 'hsl(220 12% 10% / 1)', // 'base.900' + width, + height, + listening: false, + perfectDrawEnabled: false, + }), + text: new Konva.Text({ + name: `${this.type}:placeholder_text`, + fill: 'hsl(220 12% 80% / 1)', // 'base.900' + width, + height, + align: 'center', + verticalAlign: 'middle', + fontFamily: '"Inter Variable", sans-serif', + fontSize: width / 24, + fontStyle: '600', + text: 'Waiting for Image', + listening: false, + perfectDrawEnabled: false, + }), + }, + }; + + this.konva.placeholder.group.add(this.konva.placeholder.rect); + this.konva.placeholder.group.add(this.konva.placeholder.text); + this.konva.group.add(this.konva.placeholder.group); + this.image = null; /** @@ -67,14 +118,23 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { ); } + syncPlaceholderSize = () => { + const { width, height } = this.manager.stateApi.getBbox().rect; + this.konva.placeholder.rect.width(width); + this.konva.placeholder.rect.height(height); + this.konva.placeholder.text.width(width); + this.konva.placeholder.text.height(height); + this.konva.placeholder.text.fontSize(width / 24); + }; + initialize = () => { this.log.debug('Initializing module'); this.render(); this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging)); }; - getImageFromSrc = ( - { type, data }: { type: 'imageName'; data: string } | { type: 'dataURL'; data: string }, + private _getImageFromSrc = ( + { type, data }: ImageNameSrc | DataURLSrc, width: number, height: number ): CanvasImageState['image'] => { @@ -106,7 +166,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { const imageSrc = this.$imageSrc.get(); if (imageSrc) { - const image = this.getImageFromSrc(imageSrc, width, height); + const image = this._getImageFromSrc(imageSrc, width, height); if (!this.image) { this.image = new CanvasObjectImage({ id: 'staging-area-image', type: 'image', image }, this); await this.image.update(this.image.state, true); @@ -116,11 +176,15 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { } else { await this.image.update({ ...this.image.state, image }); } + this.konva.placeholder.group.visible(false); } else { this.image?.destroy(); this.image = null; + this.syncPlaceholderSize(); + this.konva.placeholder.group.visible(true); } - this.konva.group.visible(shouldShowStagedImage); + + this.konva.group.visible(shouldShowStagedImage && this.$isStaging.get()); } finally { release(); } From 526e6335a133ec73b204c278e312b37a1a09501b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:34:36 +1000 Subject: [PATCH 057/210] feat(ui): improved staging placeholders --- .../components/SimpleSession/QueueItemProgressImage.tsx | 2 +- .../components/SimpleSession/StagingAreaContent.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 64c9924c27..0cf59fd290 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -10,7 +10,7 @@ export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => { const { progressImage } = useProgressData(ctx.$progressData, itemId); if (!progressImage) { - return ; + return ; } return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx index 76cafe25ea..0f60379962 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx @@ -11,7 +11,7 @@ export const StagingAreaContent = memo(() => { - + From 9bbc31b2d97ae47b0e184116fe2a411a32c83a7e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:38:08 +1000 Subject: [PATCH 058/210] fix(ui): reset layers when changing session type --- .../web/src/features/controlLayers/store/canvasSlice.ts | 4 ++-- .../controlLayers/store/canvasStagingAreaSlice.ts | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index cda6f3742b..22eee9b0c2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -6,7 +6,7 @@ import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasReset } from 'features/controlLayers/store/actions'; -import { canvasSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { modelChanged } from 'features/controlLayers/store/paramsSlice'; import { selectAllEntities, @@ -1846,7 +1846,7 @@ export const canvasSlice = createSlice({ syncScaledSize(state); } }); - builder.addCase(canvasSessionReset, (state) => resetState(state)); + builder.addCase(canvasSessionTypeChanged, (state) => resetState(state)); }, }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts index bbaabff71a..3bb050a4bb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasStagingAreaSlice.ts @@ -37,19 +37,14 @@ export const canvasSessionSlice = createSlice({ canvasSessionGenerationFinished: (state) => { state.id = null; }, - canvasSessionReset: () => getInitialState(), }, extraReducers(builder) { builder.addCase(canvasReset, () => getInitialState()); }, }); -export const { - canvasSessionTypeChanged, - canvasSessionGenerationStarted, - canvasSessionReset, - canvasSessionGenerationFinished, -} = canvasSessionSlice.actions; +export const { canvasSessionTypeChanged, canvasSessionGenerationStarted, canvasSessionGenerationFinished } = + canvasSessionSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const migrate = (state: any): any => { From 3038a797a6ae3726deb5ec684d6af2ad77b1b561 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:42:07 +1000 Subject: [PATCH 059/210] fix(ui): ensure canvas tool modules are destroyed --- .../controlLayers/konva/CanvasTool/CanvasToolModule.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts index 762ae693d3..b110f97d0d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts @@ -641,6 +641,9 @@ export class CanvasToolModule extends CanvasModuleBase { this.log.debug('Destroying module'); this.subscriptions.forEach((unsubscribe) => unsubscribe()); this.subscriptions.clear(); + for (const tool of Object.values(this.tools)) { + tool.destroy(); + } this.konva.group.destroy(); }; } From bf5ed61b84f4cfd8b898474be9d3a74e62f6389e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:58:50 +1000 Subject: [PATCH 060/210] feat(ui): add staging area toolbar to simple session --- .../SimpleSession/StagingAreaContent.tsx | 4 +++ .../StagingArea/SimpleStagingAreaToolbar.tsx | 25 +++++++++++++++++++ .../StagingArea/StagingAreaToolbar.tsx | 13 +++++++--- .../StagingAreaToolbarDiscardAllButton.tsx | 8 ++---- ...tagingAreaToolbarDiscardSelectedButton.tsx | 7 ++---- .../StagingAreaToolbarNextButton.tsx | 11 +++----- .../StagingAreaToolbarPrevButton.tsx | 11 +++----- 7 files changed, 50 insertions(+), 29 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx index 0f60379962..0e5c230204 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx @@ -2,6 +2,7 @@ import { Divider, Flex } from '@invoke-ai/ui-library'; import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; import { StagingAreaSelectedItem } from 'features/controlLayers/components/SimpleSession/StagingAreaSelectedItem'; +import { SimpleStagingAreaToolbar } from 'features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar'; import { memo } from 'react'; export const StagingAreaContent = memo(() => { @@ -14,6 +15,9 @@ export const StagingAreaContent = memo(() => { + + + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx new file mode 100644 index 0000000000..26511981f3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx @@ -0,0 +1,25 @@ +import { ButtonGroup } from '@invoke-ai/ui-library'; +import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; +import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; +import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton'; +import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton'; +import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton'; +import { memo } from 'react'; + +export const SimpleStagingAreaToolbar = memo(() => { + return ( + <> + + + + + + + + + + + ); +}); + +SimpleStagingAreaToolbar.displayName = 'SimpleStagingAreaToolbar'; 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 4ac13e74a3..63926e766d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -1,4 +1,5 @@ import { ButtonGroup } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton'; @@ -10,10 +11,14 @@ import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/ import { StagingAreaToolbarSaveAsMenu } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu'; 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, useEffect } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; export const StagingAreaToolbar = memo(() => { + const canvasManager = useCanvasManager(); + const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); + const ctx = useCanvasSessionContext(); useEffect(() => { @@ -29,17 +34,17 @@ export const StagingAreaToolbar = memo(() => { return ( <> - + - + - - + + ); 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 99514b2b96..d3cc4b20e4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx @@ -1,21 +1,17 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; -export const StagingAreaToolbarDiscardAllButton = memo(() => { - const canvasManager = useCanvasManager(); +export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { const ctx = useCanvasSessionContext(); const dispatch = useAppDispatch(); const { t } = useTranslation(); const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation(); - const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const discardAll = useCallback(() => { deleteByDestination({ destination: ctx.session.id }); @@ -30,7 +26,7 @@ export const StagingAreaToolbarDiscardAllButton = memo(() => { onClick={discardAll} colorScheme="error" fontSize={16} - isDisabled={!shouldShowStagedImage} + isDisabled={isDisabled} /> ); }); 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 f6608672b5..7006a30aa3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -1,18 +1,15 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue'; -export const StagingAreaToolbarDiscardSelectedButton = memo(() => { - const canvasManager = useCanvasManager(); +export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { const ctx = useCanvasSessionContext(); const [deleteQueueItem] = useDeleteQueueItemMutation(); const selectedItemId = useStore(ctx.$selectedItemId); - const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const { t } = useTranslation(); @@ -31,7 +28,7 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(() => { onClick={discardSelected} colorScheme="invokeBlue" fontSize={16} - isDisabled={selectedItemId === null || !shouldShowStagedImage} + isDisabled={selectedItemId === null || isDisabled} /> ); }); 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 0a5b5e50de..91eec2f6de 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton.tsx @@ -2,17 +2,14 @@ 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 { 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(() => { +export const StagingAreaToolbarNextButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { const ctx = useCanvasSessionContext(); const itemCount = useStore(ctx.$itemCount); - const canvasManager = useCanvasManager(); - const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); const { t } = useTranslation(); @@ -26,9 +23,9 @@ export const StagingAreaToolbarNextButton = memo(() => { ctx.selectNext, { preventDefault: true, - enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1, + enabled: isCanvasFocused && !isDisabled && itemCount > 1, }, - [isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectNext] + [isCanvasFocused, isDisabled, itemCount, ctx.selectNext] ); return ( @@ -38,7 +35,7 @@ export const StagingAreaToolbarNextButton = memo(() => { icon={} onClick={selectNext} colorScheme="invokeBlue" - isDisabled={itemCount <= 1 || !shouldShowStagedImage} + isDisabled={itemCount <= 1 || isDisabled} /> ); }); 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 430fdf9629..cbed5ab675 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton.tsx @@ -2,17 +2,14 @@ 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 { 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(() => { +export const StagingAreaToolbarPrevButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { const ctx = useCanvasSessionContext(); const itemCount = useStore(ctx.$itemCount); - const canvasManager = useCanvasManager(); - const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); const { t } = useTranslation(); @@ -26,9 +23,9 @@ export const StagingAreaToolbarPrevButton = memo(() => { ctx.selectPrev, { preventDefault: true, - enabled: isCanvasFocused && shouldShowStagedImage && itemCount > 1, + enabled: isCanvasFocused && !isDisabled && itemCount > 1, }, - [isCanvasFocused, shouldShowStagedImage, itemCount, ctx.selectPrev] + [isCanvasFocused, isDisabled, itemCount, ctx.selectPrev] ); return ( @@ -38,7 +35,7 @@ export const StagingAreaToolbarPrevButton = memo(() => { icon={} onClick={selectPrev} colorScheme="invokeBlue" - isDisabled={itemCount <= 1 || !shouldShowStagedImage} + isDisabled={itemCount <= 1 || isDisabled} /> ); }); From cd0668dd0bbcd8cb7c0f41cd65c30fc66353fca5 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:58:59 +1000 Subject: [PATCH 061/210] feat(ui): tweak staging image display --- .../components/SimpleSession/QueueItemPreviewMini.tsx | 2 +- .../SimpleSession/QueueItemProgressImage.tsx | 10 +++++++--- invokeai/frontend/web/src/features/dnd/DndImage.tsx | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) 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 9a902b0ab7..ee27ae5f35 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -62,7 +62,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = > {imageDTO && } - {!imageLoaded && } + {!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 0cf59fd290..e9f934f92b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -3,14 +3,18 @@ import { Flex, Image } from '@invoke-ai/ui-library'; import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; import { memo } from 'react'; -type Props = { itemId: number } & ImageProps; +type Props = { itemId: number; withBg?: boolean } & ImageProps; -export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => { +export const QueueItemProgressImage = memo(({ itemId, withBg, ...rest }: Props) => { const ctx = useCanvasSessionContext(); const { progressImage } = useProgressData(ctx.$progressData, itemId); if (!progressImage) { - return ; + if (withBg) { + return ; + } else { + return null; + } } return ( diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 9009fe440f..9aed1798b4 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -77,7 +77,8 @@ export const DndImage = memo( ref={ref} src={asThumbnail ? imageDTO.thumbnail_url : imageDTO.image_url} fallbackSrc={asThumbnail ? undefined : imageDTO.thumbnail_url} - w={imageDTO.width} + width={imageDTO.width} + height={imageDTO.height} sx={sx} data-is-dragging={isDragging} {...rest} From 23511d68dbd8b29dba8e603a11bd7b3663550e37 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 20:03:26 +1000 Subject: [PATCH 062/210] feat(ui): when discarding last item, select new last instead of first --- .../controlLayers/components/SimpleSession/context.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 5f1c73fdb0..33b5fc9cc1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -344,7 +344,10 @@ export const CanvasSessionContextProvider = memo( // 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. if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) { - const prevIndex = $prevItems.get().findIndex(({ item_id }) => item_id === selectedItemId); + let prevIndex = $prevItems.get().findIndex(({ item_id }) => item_id === selectedItemId); + if (prevIndex >= items.length) { + prevIndex = items.length - 1; + } const nextItem = items[prevIndex]; $selectedItemId.set(nextItem?.item_id ?? null); return; From bc3550f23820540eee8c949a7fa637660d589ae0 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 20:03:47 +1000 Subject: [PATCH 063/210] feat(ui): finish generation when discarding last item --- .../StagingAreaToolbarDiscardSelectedButton.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 7006a30aa3..e1c0738fe9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -1,12 +1,15 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; +import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { + const dispatch = useAppDispatch(); const ctx = useCanvasSessionContext(); const [deleteQueueItem] = useDeleteQueueItemMutation(); const selectedItemId = useStore(ctx.$selectedItemId); @@ -17,8 +20,12 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i if (selectedItemId === null) { return; } + const itemCount = ctx.$itemCount.get(); deleteQueueItem({ item_id: selectedItemId }); - }, [selectedItemId, deleteQueueItem]); + if (itemCount <= 1) { + dispatch(canvasSessionGenerationFinished()); + } + }, [selectedItemId, ctx.$itemCount, deleteQueueItem, dispatch]); return ( Date: Thu, 5 Jun 2025 20:10:08 +1000 Subject: [PATCH 064/210] build(ui): temporarily ignore all knip issues --- invokeai/frontend/web/knip.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/invokeai/frontend/web/knip.ts b/invokeai/frontend/web/knip.ts index 9aeb58061c..3f741d0f76 100644 --- a/invokeai/frontend/web/knip.ts +++ b/invokeai/frontend/web/knip.ts @@ -3,6 +3,8 @@ import type { KnipConfig } from 'knip'; const config: KnipConfig = { project: ['src/**/*.{ts,tsx}!'], ignore: [ + // TODO(psyche): temporarily ignored all files for test build purposes + 'src/**', // This file is only used during debugging 'src/app/store/middleware/debugLoggerMiddleware.ts', // Autogenerated types - shouldn't ever touch these From 2431060a7ed9a03b039c571b480515962775a7f8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 21:09:42 +1000 Subject: [PATCH 065/210] fix(ui): hide layers when not on canvas tab --- .../web/src/features/ui/components/RightPanelContent.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx index 80d3ed935f..6eb9eb761f 100644 --- a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx @@ -12,6 +12,7 @@ import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle'; import type { UsePanelOptions } from 'features/ui/hooks/usePanel'; import { usePanel } from 'features/ui/hooks/usePanel'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useMemo, useRef } from 'react'; import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels'; @@ -29,6 +30,7 @@ export const RightPanelContent = memo(() => { const boardSearchDisclosure = useDisclosure({ defaultIsOpen: !!boardSearchText.length }); const imperativePanelGroupRef = useRef(null); const type = useAppSelector(selectCanvasSessionType); + const tab = useAppSelector(selectActiveTab); const boardsListPanelOptions = useMemo( () => ({ @@ -77,7 +79,7 @@ export const RightPanelContent = memo(() => { - {type === 'advanced' && ( + {tab === 'canvas' && type === 'advanced' && ( <> From cc5083599d591d740f873a6575c47961b785af38 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 5 Jun 2025 21:13:26 +1000 Subject: [PATCH 066/210] fix(ui): mini preview bg color --- .../SimpleSession/QueueItemPreviewMini.tsx | 3 ++- .../SimpleSession/QueueItemProgressImage.tsx | 12 ++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) 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 ee27ae5f35..3393d7a18e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -21,6 +21,7 @@ const sx = { flexShrink: 0, borderWidth: 2, borderRadius: 'base', + bg: 'base.900', '&[data-selected="true"]': { borderColor: 'invokeBlue.300', }, @@ -62,7 +63,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = > {imageDTO && } - {!imageLoaded && } + {!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 e9f934f92b..2ea3dd827e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressImage.tsx @@ -1,20 +1,16 @@ import type { ImageProps } from '@invoke-ai/ui-library'; -import { Flex, Image } 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'; -type Props = { itemId: number; withBg?: boolean } & ImageProps; +type Props = { itemId: number } & ImageProps; -export const QueueItemProgressImage = memo(({ itemId, withBg, ...rest }: Props) => { +export const QueueItemProgressImage = memo(({ itemId, ...rest }: Props) => { const ctx = useCanvasSessionContext(); const { progressImage } = useProgressData(ctx.$progressData, itemId); if (!progressImage) { - if (withBg) { - return ; - } else { - return null; - } + return null; } return ( From 2ddcde13ffc67acde1fd86f1ab70365bd8dfc304 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:29:43 +1000 Subject: [PATCH 067/210] refactor(ui): migrate from canceling queue items to deleteing, make queue hook APIs consistent --- invokeai/app/api/routers/session_queue.py | 13 +++ .../session_queue/session_queue_base.py | 6 ++ .../session_queue/session_queue_common.py | 6 ++ .../session_queue/session_queue_sqlite.py | 32 +++++++ .../app/components/GlobalModalIsolator.tsx | 2 + .../web/src/common/hooks/useGlobalHotkeys.ts | 22 ++--- .../components/SimpleSession/context.tsx | 86 ++++++------------- .../StagingAreaToolbarAcceptButton.tsx | 18 ++-- .../StagingAreaToolbarDiscardAllButton.tsx | 11 +-- ...tagingAreaToolbarDiscardSelectedButton.tsx | 9 +- .../CancelAllExceptCurrentIconButton.tsx | 25 ++++++ ...urrentQueueItemConfirmationAlertDialog.tsx | 11 ++- .../components/ClearInvocationCacheButton.tsx | 8 +- .../ClearQueueConfirmationAlertDialog.tsx | 11 ++- .../queue/components/ClearQueueIconButton.tsx | 58 ------------- ...urrentQueueItemConfirmationAlertDialog.tsx | 46 ++++++++++ .../DeleteCurrentQueueItemIconButton.tsx | 25 ++++++ .../queue/components/PauseProcessorButton.tsx | 8 +- .../queue/components/PruneQueueButton.tsx | 11 +-- .../components/QueueActionsMenuButton.tsx | 44 ++++------ .../queue/components/QueueControls.tsx | 19 +++- .../QueueList/QueueItemComponent.tsx | 26 +++--- .../components/QueueList/QueueItemDetail.tsx | 42 ++++++--- .../components/QueueTabQueueControls.tsx | 4 +- .../components/ResumeProcessorButton.tsx | 8 +- .../ToggleInvocationCacheButton.tsx | 24 +++--- .../queue/hooks/useBatchIsCanceled.ts | 20 +++++ .../useCancelAllExceptCurrentQueueItem.ts | 22 ++--- .../features/queue/hooks/useCancelBatch.ts | 60 +++++-------- .../queue/hooks/useCancelCurrentQueueItem.ts | 47 +++------- .../queue/hooks/useCancelQueueItem.ts | 41 +++++---- .../hooks/useCancelQueueItemsByDestination.ts | 33 +++++++ .../queue/hooks/useClearInvocationCache.ts | 18 ++-- .../src/features/queue/hooks/useClearQueue.ts | 19 ++-- .../queue/hooks/useCurrentDestination.ts | 11 --- .../queue/hooks/useCurrentQueueItemId.ts | 11 +++ .../useDeleteAllExceptCurrentQueueItem.ts | 38 ++++++++ .../queue/hooks/useDeleteCurrentQueueItem.ts | 20 +++++ .../queue/hooks/useDeleteQueueItem.ts | 33 +++++++ .../hooks/useDeleteQueueItemsByDestination.ts | 33 +++++++ .../queue/hooks/useDisableInvocationCache.ts | 26 +++--- .../queue/hooks/useEnableInvocationCache.ts | 25 +++--- .../features/queue/hooks/usePauseProcessor.ts | 19 ++-- .../src/features/queue/hooks/usePruneQueue.ts | 45 +++++----- .../queue/hooks/useResumeProcessor.ts | 19 ++-- .../features/queue/hooks/useRetryQueueItem.ts | 45 +++++----- .../system/components/ProgressBar.tsx | 14 +-- .../components/FloatingLeftPanelButtons.tsx | 32 +++---- .../web/src/services/api/endpoints/queue.ts | 20 ++++- .../frontend/web/src/services/api/schema.ts | 63 ++++++++++++++ 50 files changed, 773 insertions(+), 516 deletions(-) create mode 100644 invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentIconButton.tsx delete mode 100644 invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog.tsx create mode 100644 invokeai/frontend/web/src/features/queue/components/DeleteCurrentQueueItemIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useBatchIsCanceled.ts create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts delete mode 100644 invokeai/frontend/web/src/features/queue/hooks/useCurrentDestination.ts create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useCurrentQueueItemId.ts create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useDeleteCurrentQueueItem.ts create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItemsByDestination.ts diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index f19f4bc47c..5902a492d4 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -14,6 +14,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( CancelByBatchIDsResult, CancelByDestinationResult, ClearResult, + DeleteAllExceptCurrentResult, DeleteByDestinationResult, EnqueueBatchResult, FieldIdentifier, @@ -146,6 +147,18 @@ async def cancel_all_except_current( return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(queue_id=queue_id) +@session_queue_router.put( + "/{queue_id}/delete_all_except_current", + operation_id="delete_all_except_current", + responses={200: {"model": DeleteAllExceptCurrentResult}}, +) +async def delete_all_except_current( + queue_id: str = Path(description="The queue id to perform this operation on"), +) -> DeleteAllExceptCurrentResult: + """Immediately deletes all queue items except in-processing items""" + return ApiDependencies.invoker.services.session_queue.delete_all_except_current(queue_id=queue_id) + + @session_queue_router.put( "/{queue_id}/cancel_by_batch_ids", operation_id="cancel_by_batch_ids", diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 17c31e77a7..aa1126576d 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -10,6 +10,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( CancelByDestinationResult, CancelByQueueIDResult, ClearResult, + DeleteAllExceptCurrentResult, DeleteByDestinationResult, EnqueueBatchResult, IsEmptyResult, @@ -129,6 +130,11 @@ class SessionQueueBase(ABC): """Cancels all queue items except in-progress items""" pass + @abstractmethod + def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult: + """Deletes all queue items except in-progress items""" + pass + @abstractmethod def list_queue_items( self, diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index d41fb44533..9075732901 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -369,6 +369,12 @@ class DeleteByDestinationResult(BaseModel): deleted: int = Field(..., description="Number of queue items deleted") +class DeleteAllExceptCurrentResult(DeleteByDestinationResult): + """Result of deleting all except current""" + + pass + + class CancelByQueueIDResult(CancelByBatchIDsResult): """Result of canceling by queue id""" diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index c31226581a..36b4f05cf3 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -17,6 +17,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( CancelByDestinationResult, CancelByQueueIDResult, ClearResult, + DeleteAllExceptCurrentResult, DeleteByDestinationResult, EnqueueBatchResult, IsEmptyResult, @@ -489,6 +490,37 @@ class SqliteSessionQueue(SessionQueueBase): raise return DeleteByDestinationResult(deleted=count) + def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult: + try: + cursor = self._conn.cursor() + where = """--sql + WHERE + queue_id == ? + AND status == 'pending' + """ + cursor.execute( + f"""--sql + SELECT COUNT(*) + FROM session_queue + {where}; + """, + (queue_id,), + ) + count = cursor.fetchone()[0] + cursor.execute( + f"""--sql + DELETE + FROM session_queue + {where}; + """, + (queue_id,), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + return DeleteAllExceptCurrentResult(deleted=count) + def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: try: cursor = self._conn.cursor() diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index ae83f0c5c2..782b338fe4 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -16,6 +16,7 @@ import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal'; import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; +import { DeleteAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog'; import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal'; import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal'; @@ -40,6 +41,7 @@ export const GlobalModalIsolator = memo(() => { + diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index 76c2db9a6d..88fc2bfdfb 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -1,6 +1,6 @@ import { useAppDispatch } from 'app/store/storeHooks'; -import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; import { useClearQueue } from 'features/queue/hooks/useClearQueue'; +import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem'; import { useInvoke } from 'features/queue/hooks/useInvoke'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; @@ -35,34 +35,30 @@ export const useGlobalHotkeys = () => { dependencies: [queue], }); - const { - cancelQueueItem, - isDisabled: isDisabledCancelQueueItem, - isLoading: isLoadingCancelQueueItem, - } = useCancelCurrentQueueItem(); + const deleteCurrentQueueItem = useDeleteCurrentQueueItem(); useRegisteredHotkeys({ id: 'cancelQueueItem', category: 'app', - callback: cancelQueueItem, + callback: deleteCurrentQueueItem.trigger, options: { - enabled: !isDisabledCancelQueueItem && !isLoadingCancelQueueItem, + enabled: !deleteCurrentQueueItem.isDisabled && !deleteCurrentQueueItem.isLoading, preventDefault: true, }, - dependencies: [cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem], + dependencies: [deleteCurrentQueueItem], }); - const { clearQueue, isDisabled: isDisabledClearQueue, isLoading: isLoadingClearQueue } = useClearQueue(); + const clearQueue = useClearQueue(); useRegisteredHotkeys({ id: 'clearQueue', category: 'app', - callback: clearQueue, + callback: clearQueue.trigger, options: { - enabled: !isDisabledClearQueue && !isLoadingClearQueue, + enabled: !clearQueue.isDisabled && !clearQueue.isLoading, preventDefault: true, }, - dependencies: [clearQueue, isDisabledClearQueue, isLoadingClearQueue], + dependencies: [clearQueue], }); useRegisteredHotkeys({ 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 33b5fc9cc1..daf843f947 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -67,34 +67,6 @@ const setProgress = ($progressData: WritableAtom>, } }; -const clearProgressEvent = ($progressData: WritableAtom>, itemId: number) => { - const progressData = $progressData.get(); - const current = progressData[itemId]; - if (!current) { - return; - } - const next = { ...current }; - next.progressEvent = null; - $progressData.set({ - ...progressData, - [itemId]: next, - }); -}; - -const clearProgressImage = ($progressData: WritableAtom>, itemId: number) => { - const progressData = $progressData.get(); - const current = progressData[itemId]; - if (!current) { - return; - } - const next = { ...current }; - next.progressImage = null; - $progressData.set({ - ...progressData, - [itemId]: next, - }); -}; - type CanvasSessionContextValue = { session: { id: string; type: 'simple' | 'advanced' }; $items: Atom; @@ -132,15 +104,11 @@ export const CanvasSessionContextProvider = memo( const socket = useStore($socket); /** - * Manually-synced atom containing the queue items for the current session. + * 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]; - /** - * Manually-synced atom containing the queue items for the current session. - */ - const $prevItems = useState(() => atom([]))[0]; - /** * Whether auto-switch is enabled. */ @@ -294,28 +262,16 @@ export const CanvasSessionContextProvider = memo( setProgress($progressData, data); }; - const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { - if (data.destination !== session.id) { - return; - } - - if (data.status === 'canceled' || data.status === 'failed') { - clearProgressEvent($progressData, data.item_id); - clearProgressImage($progressData, 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); }; }, [$autoSwitch, $progressData, $selectedItemId, session.id, socket]); // Set up state subscriptions and effects useEffect(() => { + let _prevItems: readonly S['SessionQueueItem'][] = []; // Seed the $items atom with the initial query cache state $items.set(selectQueueItems(store.getState())); @@ -324,7 +280,7 @@ export const CanvasSessionContextProvider = memo( const prevItems = $items.get(); const items = selectQueueItems(store.getState()); if (items !== prevItems) { - $prevItems.set(prevItems); + _prevItems = prevItems; $items.set(items); } }); @@ -344,7 +300,7 @@ export const CanvasSessionContextProvider = memo( // 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. if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) { - let prevIndex = $prevItems.get().findIndex(({ item_id }) => item_id === selectedItemId); + let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId); if (prevIndex >= items.length) { prevIndex = items.length - 1; } @@ -357,19 +313,37 @@ export const CanvasSessionContextProvider = memo( // Clean up the progress data when a queue item is discarded. const unsubCleanUpProgressData = $items.listen((items) => { const progressData = $progressData.get(); + const toDelete: number[] = []; + const toClear: number[] = []; + for (const datum of Object.values(progressData)) { - if (items.findIndex(({ item_id }) => item_id === datum.itemId) === -1) { + const item = items.find(({ item_id }) => item_id === datum.itemId); + if (!item) { toDelete.push(datum.itemId); + } else if (item.status === 'canceled' || item.status === 'failed') { + toClear.push(datum.itemId); } } + if (toDelete.length === 0) { return; } + const newProgressData = { ...progressData }; + for (const itemId of toDelete) { delete newProgressData[itemId]; } + + for (const itemId of toClear) { + const current = newProgressData[itemId]; + if (current) { + current.progressEvent = null; + current.progressImage = null; + } + } + $progressData.set(newProgressData); }); @@ -408,17 +382,7 @@ export const CanvasSessionContextProvider = memo( $progressData.set({}); $selectedItemId.set(null); }; - }, [ - $autoSwitch, - $items, - $lastLoadedItemId, - $prevItems, - $progressData, - $selectedItemId, - selectQueueItems, - session.id, - store, - ]); + }, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]); const value = useMemo( () => ({ 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 b7280327eb..1272887abc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -9,11 +9,11 @@ import { canvasSessionGenerationFinished } from 'features/controlLayers/store/ca import { selectBboxRect, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { imageNameToImageObject } from 'features/controlLayers/store/util'; +import { useDeleteQueueItemsByDestination } from 'features/queue/hooks/useDeleteQueueItemsByDestination'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiCheckBold } from 'react-icons/pi'; -import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarAcceptButton = memo(() => { const ctx = useCanvasSessionContext(); @@ -24,7 +24,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => { const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); const selectedItemImageName = useStore(ctx.$selectedItemOutputImageName); - const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation(); + const deleteQueueItemsByDestination = useDeleteQueueItemsByDestination(); const { t } = useTranslation(); @@ -41,8 +41,15 @@ export const StagingAreaToolbarAcceptButton = memo(() => { dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' })); dispatch(canvasSessionGenerationFinished()); - deleteByDestination({ destination: ctx.session.id }); - }, [selectedItemImageName, bboxRect, dispatch, selectedEntityIdentifier?.type, deleteByDestination, ctx.session.id]); + deleteQueueItemsByDestination.trigger(ctx.session.id); + }, [ + selectedItemImageName, + bboxRect, + dispatch, + selectedEntityIdentifier?.type, + deleteQueueItemsByDestination, + ctx.session.id, + ]); useHotkeys( ['enter'], @@ -61,7 +68,8 @@ export const StagingAreaToolbarAcceptButton = memo(() => { icon={} onClick={acceptSelected} colorScheme="invokeBlue" - isDisabled={!selectedItemImageName || !shouldShowStagedImage} + isDisabled={!selectedItemImageName || !shouldShowStagedImage || deleteQueueItemsByDestination.isDisabled} + isLoading={deleteQueueItemsByDestination.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 d3cc4b20e4..9c7f653898 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton.tsx @@ -2,21 +2,21 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { useDeleteQueueItemsByDestination } from 'features/queue/hooks/useDeleteQueueItemsByDestination'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; -import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarDiscardAllButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { const ctx = useCanvasSessionContext(); const dispatch = useAppDispatch(); const { t } = useTranslation(); - const [deleteByDestination] = useDeleteQueueItemsByDestinationMutation(); + const deleteQueueItemsByDestination = useDeleteQueueItemsByDestination(); const discardAll = useCallback(() => { - deleteByDestination({ destination: ctx.session.id }); + deleteQueueItemsByDestination.trigger(ctx.session.id); dispatch(canvasSessionGenerationFinished()); - }, [deleteByDestination, ctx.session.id, dispatch]); + }, [deleteQueueItemsByDestination, ctx.session.id, dispatch]); return ( ); }); 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 e1c0738fe9..b6fc9d934a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -3,15 +3,15 @@ import { useStore } from '@nanostores/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { canvasSessionGenerationFinished } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue'; export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { isDisabled?: boolean }) => { const dispatch = useAppDispatch(); const ctx = useCanvasSessionContext(); - const [deleteQueueItem] = useDeleteQueueItemMutation(); + const deleteQueueItem = useDeleteQueueItem(); const selectedItemId = useStore(ctx.$selectedItemId); const { t } = useTranslation(); @@ -21,10 +21,10 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i return; } const itemCount = ctx.$itemCount.get(); - deleteQueueItem({ item_id: selectedItemId }); if (itemCount <= 1) { dispatch(canvasSessionGenerationFinished()); } + deleteQueueItem.trigger(selectedItemId); }, [selectedItemId, ctx.$itemCount, deleteQueueItem, dispatch]); return ( @@ -35,7 +35,8 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i onClick={discardSelected} colorScheme="invokeBlue" fontSize={16} - isDisabled={selectedItemId === null || isDisabled} + isDisabled={selectedItemId === null || deleteQueueItem.isDisabled || isDisabled} + isLoading={deleteQueueItem.isLoading} /> ); }); diff --git a/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentIconButton.tsx b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentIconButton.tsx new file mode 100644 index 0000000000..1d83b409b6 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentIconButton.tsx @@ -0,0 +1,25 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiXCircle } from 'react-icons/pi'; + +export const CancelAllExceptCurrentIconButton = memo(() => { + const { t } = useTranslation(); + const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); + + return ( + } + colorScheme="error" + onClick={cancelAllExceptCurrent.openDialog} + /> + ); +}); + +CancelAllExceptCurrentIconButton.displayName = 'CancelAllExceptCurrentIconButton'; diff --git a/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog.tsx index cdaaebcdf0..a27914a4c1 100644 --- a/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog.tsx @@ -9,16 +9,15 @@ const [useCancelAllExceptCurrentQueueItemConfirmationAlertDialog] = buildUseBool export const useCancelAllExceptCurrentQueueItemDialog = () => { const dialog = useCancelAllExceptCurrentQueueItemConfirmationAlertDialog(); - const { cancelAllExceptCurrentQueueItem, isLoading, isDisabled, queueStatus } = useCancelAllExceptCurrentQueueItem(); + const cancelAllExceptCurrentQueueItem = useCancelAllExceptCurrentQueueItem(); return { - cancelAllExceptCurrentQueueItem, + trigger: cancelAllExceptCurrentQueueItem.trigger, isOpen: dialog.isTrue, openDialog: dialog.setTrue, closeDialog: dialog.setFalse, - isLoading, - queueStatus, - isDisabled, + isLoading: cancelAllExceptCurrentQueueItem.isLoading, + isDisabled: cancelAllExceptCurrentQueueItem.isDisabled, }; }; @@ -32,7 +31,7 @@ export const CancelAllExceptCurrentQueueItemConfirmationAlertDialog = memo(() => isOpen={cancelAllExceptCurrentQueueItem.isOpen} onClose={cancelAllExceptCurrentQueueItem.closeDialog} title={t('queue.cancelAllExceptCurrentTooltip')} - acceptCallback={cancelAllExceptCurrentQueueItem.cancelAllExceptCurrentQueueItem} + acceptCallback={cancelAllExceptCurrentQueueItem.trigger} acceptButtonText={t('queue.confirm')} useInert={false} > diff --git a/invokeai/frontend/web/src/features/queue/components/ClearInvocationCacheButton.tsx b/invokeai/frontend/web/src/features/queue/components/ClearInvocationCacheButton.tsx index 52b1b237e7..6a9f157361 100644 --- a/invokeai/frontend/web/src/features/queue/components/ClearInvocationCacheButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/ClearInvocationCacheButton.tsx @@ -5,10 +5,14 @@ import { useTranslation } from 'react-i18next'; const ClearInvocationCacheButton = () => { const { t } = useTranslation(); - const { clearInvocationCache, isDisabled, isLoading } = useClearInvocationCache(); + const clearInvocationCache = useClearInvocationCache(); return ( - ); diff --git a/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx index 9de86f9eb7..be1bf7ccb5 100644 --- a/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx +++ b/invokeai/frontend/web/src/features/queue/components/ClearQueueConfirmationAlertDialog.tsx @@ -9,16 +9,15 @@ const [useClearQueueConfirmationAlertDialog] = buildUseBoolean(false); const useClearQueueDialog = () => { const dialog = useClearQueueConfirmationAlertDialog(); - const { clearQueue, isLoading, isDisabled, queueStatus } = useClearQueue(); + const clearQueue = useClearQueue(); return { - clearQueue, isOpen: dialog.isTrue, openDialog: dialog.setTrue, closeDialog: dialog.setFalse, - isLoading, - queueStatus, - isDisabled, + trigger: clearQueue.trigger, + isLoading: clearQueue.isLoading, + isDisabled: clearQueue.isDisabled, }; }; @@ -32,7 +31,7 @@ export const ClearQueueConfirmationsAlertDialog = memo(() => { isOpen={clearQueue.isOpen} onClose={clearQueue.closeDialog} title={t('queue.clearTooltip')} - acceptCallback={clearQueue.clearQueue} + acceptCallback={clearQueue.trigger} acceptButtonText={t('queue.clear')} useInert={false} > diff --git a/invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx b/invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx deleted file mode 100644 index 9f7a4290fa..0000000000 --- a/invokeai/frontend/web/src/features/queue/components/ClearQueueIconButton.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { IconButton, useShiftModifier } from '@invoke-ai/ui-library'; -import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; -import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiXBold, PiXCircle } from 'react-icons/pi'; - -export const ClearQueueIconButton = memo(() => { - const shift = useShiftModifier(); - - if (!shift) { - return ; - } - - return ; -}); - -ClearQueueIconButton.displayName = 'ClearQueueIconButton'; - -const CancelCurrentIconButton = memo(() => { - const { t } = useTranslation(); - const cancelCurrentQueueItem = useCancelCurrentQueueItem(); - - return ( - } - colorScheme="error" - onClick={cancelCurrentQueueItem.cancelQueueItem} - /> - ); -}); - -CancelCurrentIconButton.displayName = 'CancelCurrentIconButton'; - -const CancelAllExceptCurrentIconButton = memo(() => { - const { t } = useTranslation(); - const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); - - return ( - } - colorScheme="error" - onClick={cancelAllExceptCurrent.openDialog} - /> - ); -}); - -CancelAllExceptCurrentIconButton.displayName = 'CancelAllExceptCurrentIconButton'; diff --git a/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog.tsx new file mode 100644 index 0000000000..ac5dc4ef93 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog.tsx @@ -0,0 +1,46 @@ +import { ConfirmationAlertDialog, Text } from '@invoke-ai/ui-library'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { buildUseBoolean } from 'common/hooks/useBoolean'; +import { useDeleteAllExceptCurrentQueueItem } from 'features/queue/hooks/useDeleteAllExceptCurrentQueueItem'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const [useDeleteAllExceptCurrentQueueItemConfirmationAlertDialog] = buildUseBoolean(false); + +export const useDeleteAllExceptCurrentQueueItemDialog = () => { + const dialog = useDeleteAllExceptCurrentQueueItemConfirmationAlertDialog(); + const deleteAllExceptCurrentQueueItem = useDeleteAllExceptCurrentQueueItem(); + + return { + trigger: deleteAllExceptCurrentQueueItem.trigger, + isOpen: dialog.isTrue, + openDialog: dialog.setTrue, + closeDialog: dialog.setFalse, + isLoading: deleteAllExceptCurrentQueueItem.isLoading, + isDisabled: deleteAllExceptCurrentQueueItem.isDisabled, + }; +}; + +export const DeleteAllExceptCurrentQueueItemConfirmationAlertDialog = memo(() => { + useAssertSingleton('DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'); + const { t } = useTranslation(); + const deleteAllExceptCurrentQueueItem = useDeleteAllExceptCurrentQueueItemDialog(); + + return ( + + {t('queue.cancelAllExceptCurrentQueueItemAlertDialog')} +
+ {t('queue.cancelAllExceptCurrentQueueItemAlertDialog2')} +
+ ); +}); + +DeleteAllExceptCurrentQueueItemConfirmationAlertDialog.displayName = + 'DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; diff --git a/invokeai/frontend/web/src/features/queue/components/DeleteCurrentQueueItemIconButton.tsx b/invokeai/frontend/web/src/features/queue/components/DeleteCurrentQueueItemIconButton.tsx new file mode 100644 index 0000000000..22009632ac --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/DeleteCurrentQueueItemIconButton.tsx @@ -0,0 +1,25 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiXBold } from 'react-icons/pi'; + +export const DeleteCurrentQueueItemIconButton = memo(() => { + const { t } = useTranslation(); + const deleteCurrentQueueItem = useDeleteCurrentQueueItem(); + + return ( + } + colorScheme="error" + /> + ); +}); + +DeleteCurrentQueueItemIconButton.displayName = 'DeleteCurrentQueueItemIconButton'; diff --git a/invokeai/frontend/web/src/features/queue/components/PauseProcessorButton.tsx b/invokeai/frontend/web/src/features/queue/components/PauseProcessorButton.tsx index 45a95951f9..36d07a1c59 100644 --- a/invokeai/frontend/web/src/features/queue/components/PauseProcessorButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/PauseProcessorButton.tsx @@ -11,17 +11,17 @@ type Props = { const PauseProcessorButton = ({ asIconButton }: Props) => { const { t } = useTranslation(); - const { pauseProcessor, isLoading, isDisabled } = usePauseProcessor(); + const pauseProcessor = usePauseProcessor(); return ( } - onClick={pauseProcessor} + onClick={pauseProcessor.trigger} colorScheme="gold" /> ); diff --git a/invokeai/frontend/web/src/features/queue/components/PruneQueueButton.tsx b/invokeai/frontend/web/src/features/queue/components/PruneQueueButton.tsx index 8ecf7a60c9..21f818b9ba 100644 --- a/invokeai/frontend/web/src/features/queue/components/PruneQueueButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/PruneQueueButton.tsx @@ -1,4 +1,4 @@ -import { usePruneQueue } from 'features/queue/hooks/usePruneQueue'; +import { useFinishedCount, usePruneQueue } from 'features/queue/hooks/usePruneQueue'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBroomBold } from 'react-icons/pi'; @@ -11,17 +11,18 @@ type Props = { const PruneQueueButton = ({ asIconButton }: Props) => { const { t } = useTranslation(); - const { pruneQueue, isLoading, finishedCount, isDisabled } = usePruneQueue(); + const pruneQueue = usePruneQueue(); + const finishedCount = useFinishedCount(); return ( } - onClick={pruneQueue} colorScheme="invokeBlue" /> ); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx index e9a81c4c19..fa7546a608 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueActionsMenuButton.tsx @@ -1,9 +1,9 @@ import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { SessionMenuItems } from 'common/components/SessionMenuItems'; -import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; import { QueueCountBadge } from 'features/queue/components/QueueCountBadge'; -import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; +import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem'; import { usePauseProcessor } from 'features/queue/hooks/usePauseProcessor'; import { useResumeProcessor } from 'features/queue/hooks/useResumeProcessor'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; @@ -18,18 +18,10 @@ export const QueueActionsMenuButton = memo(() => { const { t } = useTranslation(); const isPauseEnabled = useFeatureStatus('pauseQueue'); const isResumeEnabled = useFeatureStatus('resumeQueue'); - const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); - const cancelCurrent = useCancelCurrentQueueItem(); - const { - resumeProcessor, - isLoading: isLoadingResumeProcessor, - isDisabled: isDisabledResumeProcessor, - } = useResumeProcessor(); - const { - pauseProcessor, - isLoading: isLoadingPauseProcessor, - isDisabled: isDisabledPauseProcessor, - } = usePauseProcessor(); + const deleteAllExceptCurrent = useDeleteAllExceptCurrentQueueItemDialog(); + const deleteCurrentQueueItem = useDeleteCurrentQueueItem(); + const resumeProcessor = useResumeProcessor(); + const pauseProcessor = usePauseProcessor(); const openQueue = useCallback(() => { dispatch(setActiveTab('queue')); }, [dispatch]); @@ -46,27 +38,27 @@ export const QueueActionsMenuButton = memo(() => { } - onClick={cancelCurrent.cancelQueueItem} - isLoading={cancelCurrent.isLoading} - isDisabled={cancelCurrent.isDisabled} + onClick={deleteCurrentQueueItem.trigger} + isLoading={deleteCurrentQueueItem.isLoading} + isDisabled={deleteCurrentQueueItem.isDisabled} > {t('queue.cancelTooltip')} } - onClick={cancelAllExceptCurrent.openDialog} - isLoading={cancelAllExceptCurrent.isLoading} - isDisabled={cancelAllExceptCurrent.isDisabled} + onClick={deleteAllExceptCurrent.openDialog} + isLoading={deleteAllExceptCurrent.isLoading} + isDisabled={deleteAllExceptCurrent.isDisabled} > {t('queue.cancelAllExceptCurrentTooltip')} {isResumeEnabled && ( } - onClick={resumeProcessor} - isLoading={isLoadingResumeProcessor} - isDisabled={isDisabledResumeProcessor} + onClick={resumeProcessor.trigger} + isLoading={resumeProcessor.isLoading} + isDisabled={resumeProcessor.isDisabled} > {t('queue.resumeTooltip')} @@ -74,9 +66,9 @@ export const QueueActionsMenuButton = memo(() => { {isPauseEnabled && ( } - onClick={pauseProcessor} - isLoading={isLoadingPauseProcessor} - isDisabled={isDisabledPauseProcessor} + onClick={pauseProcessor.trigger} + isLoading={pauseProcessor.isLoading} + isDisabled={pauseProcessor.isDisabled} > {t('queue.pauseTooltip')} diff --git a/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx b/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx index d3f05cb30b..040ecc6de9 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx @@ -1,5 +1,6 @@ -import { Flex, Spacer } from '@invoke-ai/ui-library'; -import { ClearQueueIconButton } from 'features/queue/components/ClearQueueIconButton'; +import { Flex, Spacer, useShiftModifier } from '@invoke-ai/ui-library'; +import { DeleteAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { DeleteCurrentQueueItemIconButton } from 'features/queue/components/DeleteCurrentQueueItemIconButton'; import { QueueActionsMenuButton } from 'features/queue/components/QueueActionsMenuButton'; import ProgressBar from 'features/system/components/ProgressBar'; import { memo } from 'react'; @@ -13,7 +14,7 @@ const QueueControls = () => { - +
@@ -21,3 +22,15 @@ const QueueControls = () => { }; export default memo(QueueControls); + +export const DeleteIconButton = memo(() => { + const shift = useShiftModifier(); + + if (!shift) { + return ; + } + + return ; +}); + +DeleteIconButton.displayName = 'DeleteIconButton'; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx index d645dcbfe5..1f6cea985a 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx @@ -3,7 +3,7 @@ import { Badge, ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge'; import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText'; import { useOriginText } from 'features/queue/components/QueueList/useOriginText'; -import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem'; +import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem'; import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem'; import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; @@ -38,21 +38,21 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => { const handleToggle = useCallback(() => { context.toggleQueueItem(item.item_id); }, [context, item.item_id]); - const { cancelQueueItem, isLoading: isLoadingCancelQueueItem } = useCancelQueueItem(item.item_id); - const handleCancelQueueItem = useCallback( + const deleteQueueItem = useDeleteQueueItem(); + const onClickDeleteQueueItem = useCallback( (e: MouseEvent) => { e.stopPropagation(); - cancelQueueItem(); + deleteQueueItem.trigger(item.item_id); }, - [cancelQueueItem] + [deleteQueueItem, item.item_id] ); - const { retryQueueItem, isLoading: isLoadingRetryQueueItem } = useRetryQueueItem(item.item_id); - const handleRetryQueueItem = useCallback( + const retryQueueItem = useRetryQueueItem(); + const onClickRetryQueueItem = useCallback( (e: MouseEvent) => { e.stopPropagation(); - retryQueueItem(); + retryQueueItem.trigger(item.item_id); }, - [retryQueueItem] + [item.item_id, retryQueueItem] ); const isOpen = useMemo(() => context.openQueueItems.includes(item.item_id), [context.openQueueItems, item.item_id]); @@ -135,17 +135,17 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => { {(!isFailed || !isRetryEnabled || isValidationRun) && ( } /> )} {isFailed && isRetryEnabled && !isValidationRun && ( } /> diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx index a06e82ac5b..f4fc5f7c53 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemDetail.tsx @@ -2,14 +2,15 @@ import { Button, ButtonGroup, Flex, Heading, Spinner, Text } from '@invoke-ai/ui import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText'; import { useOriginText } from 'features/queue/components/QueueList/useOriginText'; +import { useBatchIsCanceled } from 'features/queue/hooks/useBatchIsCanceled'; import { useCancelBatch } from 'features/queue/hooks/useCancelBatch'; -import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem'; +import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem'; import { useRetryQueueItem } from 'features/queue/hooks/useRetryQueueItem'; import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTimestamps'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { get } from 'lodash-es'; import type { ReactNode } from 'react'; -import { memo, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi'; import type { S } from 'services/api/types'; @@ -22,9 +23,10 @@ const QueueItemComponent = ({ queueItem }: Props) => { const { session_id, batch_id, item_id, origin, destination } = queueItem; const { t } = useTranslation(); const isRetryEnabled = useFeatureStatus('retryQueueItem'); - const { cancelBatch, isLoading: isLoadingCancelBatch, isCanceled: isBatchCanceled } = useCancelBatch(batch_id); - const { cancelQueueItem, isLoading: isLoadingCancelQueueItem } = useCancelQueueItem(item_id); - const { retryQueueItem, isLoading: isLoadingRetryQueueItem } = useRetryQueueItem(item_id); + const isBatchCanceled = useBatchIsCanceled(batch_id); + const cancelBatch = useCancelBatch(); + const deleteQueueItem = useDeleteQueueItem(); + const retryQueueItem = useRetryQueueItem(); const originText = useOriginText(origin); const destinationText = useDestinationText(destination); @@ -50,6 +52,18 @@ const QueueItemComponent = ({ queueItem }: Props) => { const isFailed = useMemo(() => !!queueItem && ['canceled', 'failed'].includes(queueItem.status), [queueItem]); + const onCancelBatch = useCallback(() => { + cancelBatch.trigger(batch_id); + }, [cancelBatch, batch_id]); + + const onCancelQueueItem = useCallback(() => { + deleteQueueItem.trigger(item_id); + }, [deleteQueueItem, item_id]); + + const onRetryQueueItem = useCallback(() => { + retryQueueItem.trigger(item_id); + }, [retryQueueItem, item_id]); + return ( { {(!isFailed || !isRetryEnabled) && ( )} ); } return ( - ); diff --git a/invokeai/frontend/web/src/features/queue/hooks/useBatchIsCanceled.ts b/invokeai/frontend/web/src/features/queue/hooks/useBatchIsCanceled.ts new file mode 100644 index 0000000000..2fd82c5b3b --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useBatchIsCanceled.ts @@ -0,0 +1,20 @@ +import { useGetBatchStatusQuery } from 'services/api/endpoints/queue'; + +export const useBatchIsCanceled = (batch_id: string) => { + const { isCanceled } = useGetBatchStatusQuery( + { batch_id }, + { + selectFromResult: ({ data }) => { + if (!data) { + return { isCanceled: true }; + } + + return { + isCanceled: data?.in_progress === 0 && data?.pending === 0, + }; + }, + } + ); + + return isCanceled; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts index 3d38891545..8e6c79b96a 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useCancelAllExceptCurrentMutation, useGetQueueStatusQuery } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -9,17 +9,17 @@ export const useCancelAllExceptCurrentQueueItem = () => { const { t } = useTranslation(); const { data: queueStatus } = useGetQueueStatusQuery(); const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = useCancelAllExceptCurrentMutation({ + const [_trigger, { isLoading }] = useCancelAllExceptCurrentMutation({ fixedCacheKey: 'cancelAllExceptCurrent', }); - const cancelAllExceptCurrentQueueItem = useCallback(async () => { + const trigger = useCallback(async () => { if (!queueStatus?.queue.pending) { return; } try { - await trigger().unwrap(); + await _trigger().unwrap(); toast({ id: 'QUEUE_CANCEL_SUCCEEDED', title: t('queue.cancelSucceeded'), @@ -32,17 +32,7 @@ export const useCancelAllExceptCurrentQueueItem = () => { status: 'error', }); } - }, [queueStatus?.queue.pending, trigger, t]); + }, [queueStatus?.queue.pending, _trigger, t]); - const isDisabled = useMemo( - () => !isConnected || !queueStatus?.queue.pending, - [isConnected, queueStatus?.queue.pending] - ); - - return { - cancelAllExceptCurrentQueueItem, - isLoading, - queueStatus, - isDisabled, - }; + return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.queue.pending } as const; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelBatch.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelBatch.ts index 92d0cbb5a6..ce9202f3cf 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelBatch.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelBatch.ts @@ -2,48 +2,34 @@ import { useStore } from '@nanostores/react'; import { toast } from 'features/toast/toast'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useCancelByBatchIdsMutation, useGetBatchStatusQuery } from 'services/api/endpoints/queue'; +import { useCancelByBatchIdsMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; -export const useCancelBatch = (batch_id: string) => { +export const useCancelBatch = () => { const isConnected = useStore($isConnected); - const { isCanceled } = useGetBatchStatusQuery( - { batch_id }, - { - selectFromResult: ({ data }) => { - if (!data) { - return { isCanceled: true }; - } - - return { - isCanceled: data?.in_progress === 0 && data?.pending === 0, - }; - }, - } - ); - const [trigger, { isLoading }] = useCancelByBatchIdsMutation({ + const [_trigger, { isLoading }] = useCancelByBatchIdsMutation({ fixedCacheKey: 'cancelByBatchIds', }); const { t } = useTranslation(); - const cancelBatch = useCallback(async () => { - if (isCanceled) { - return; - } - try { - await trigger({ batch_ids: [batch_id] }).unwrap(); - toast({ - id: 'CANCEL_BATCH_SUCCEEDED', - title: t('queue.cancelBatchSucceeded'), - status: 'success', - }); - } catch { - toast({ - id: 'CANCEL_BATCH_FAILED', - title: t('queue.cancelBatchFailed'), - status: 'error', - }); - } - }, [batch_id, isCanceled, t, trigger]); + const trigger = useCallback( + async (batch_id: string) => { + try { + await _trigger({ batch_ids: [batch_id] }).unwrap(); + toast({ + id: 'CANCEL_BATCH_SUCCEEDED', + title: t('queue.cancelBatchSucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'CANCEL_BATCH_FAILED', + title: t('queue.cancelBatchFailed'), + status: 'error', + }); + } + }, + [t, _trigger] + ); - return { cancelBatch, isLoading, isCanceled, isDisabled: !isConnected }; + return { trigger, isLoading, isDisabled: !isConnected }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts index 0f9a5c48fe..509efd8a75 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts @@ -1,43 +1,20 @@ -import { useStore } from '@nanostores/react'; -import { toast } from 'features/toast/toast'; -import { isNil } from 'lodash-es'; -import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useCancelQueueItemMutation, useGetQueueStatusQuery } from 'services/api/endpoints/queue'; -import { $isConnected } from 'services/events/stores'; +import { useCancelQueueItem } from 'features/queue/hooks/useCancelQueueItem'; +import { useCurrentQueueItemId } from 'features/queue/hooks/useCurrentQueueItemId'; +import { useCallback } from 'react'; export const useCancelCurrentQueueItem = () => { - const isConnected = useStore($isConnected); - const { data: queueStatus } = useGetQueueStatusQuery(); - const [trigger, { isLoading }] = useCancelQueueItemMutation(); - const { t } = useTranslation(); - const currentQueueItemId = useMemo(() => queueStatus?.queue.item_id, [queueStatus?.queue.item_id]); - const cancelQueueItem = useCallback(async () => { - if (currentQueueItemId !== null || currentQueueItemId !== undefined) { + const currentQueueItemId = useCurrentQueueItemId(); + const cancelQueueItem = useCancelQueueItem(); + const trigger = useCallback(() => { + if (currentQueueItemId === null) { return; } - try { - await trigger({ item_id: currentQueueItemId }).unwrap(); - toast({ - id: 'QUEUE_CANCEL_SUCCEEDED', - title: t('queue.cancelSucceeded'), - status: 'success', - }); - } catch { - toast({ - id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), - status: 'error', - }); - } - }, [currentQueueItemId, t, trigger]); - - const isDisabled = useMemo(() => !isConnected || isNil(currentQueueItemId), [isConnected, currentQueueItemId]); + cancelQueueItem.trigger(currentQueueItemId); + }, [currentQueueItemId, cancelQueueItem]); return { - cancelQueueItem, - isLoading, - currentQueueItemId, - isDisabled, + trigger, + isLoading: cancelQueueItem.isLoading, + isDisabled: cancelQueueItem.isDisabled || currentQueueItemId === null, }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts index 1e59f8ec46..15de37e030 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts @@ -5,26 +5,29 @@ import { useTranslation } from 'react-i18next'; import { useCancelQueueItemMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; -export const useCancelQueueItem = (item_id: number) => { +export const useCancelQueueItem = () => { const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = useCancelQueueItemMutation(); + const [_trigger, { isLoading }] = useCancelQueueItemMutation(); const { t } = useTranslation(); - const cancelQueueItem = useCallback(async () => { - try { - await trigger({ item_id }).unwrap(); - toast({ - id: 'QUEUE_CANCEL_SUCCEEDED', - title: t('queue.cancelSucceeded'), - status: 'success', - }); - } catch { - toast({ - id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), - status: 'error', - }); - } - }, [item_id, t, trigger]); + const trigger = useCallback( + async (item_id: number) => { + try { + await _trigger({ item_id }).unwrap(); + toast({ + id: 'QUEUE_CANCEL_SUCCEEDED', + title: t('queue.cancelSucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'QUEUE_CANCEL_FAILED', + title: t('queue.cancelFailed'), + status: 'error', + }); + } + }, + [t, _trigger] + ); - return { cancelQueueItem, isLoading, isDisabled: !isConnected }; + return { trigger, isLoading, isDisabled: !isConnected }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts new file mode 100644 index 0000000000..0d85a933c7 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts @@ -0,0 +1,33 @@ +import { useStore } from '@nanostores/react'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCancelQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; +import { $isConnected } from 'services/events/stores'; + +export const useCancelQueueItemsByDestination = () => { + const isConnected = useStore($isConnected); + const [_trigger, { isLoading }] = useCancelQueueItemsByDestinationMutation(); + const { t } = useTranslation(); + const trigger = useCallback( + async (destination: string) => { + try { + await _trigger({ destination }).unwrap(); + toast({ + id: 'QUEUE_CANCEL_SUCCEEDED', + title: t('queue.cancelSucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'QUEUE_CANCEL_FAILED', + title: t('queue.cancelFailed'), + status: 'error', + }); + } + }, + [t, _trigger] + ); + + return { trigger, isLoading, isDisabled: !isConnected }; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useClearInvocationCache.ts b/invokeai/frontend/web/src/features/queue/hooks/useClearInvocationCache.ts index 19ab3cf45f..90c5a0dd3a 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useClearInvocationCache.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useClearInvocationCache.ts @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useClearInvocationCacheMutation, useGetInvocationCacheStatusQuery } from 'services/api/endpoints/appInfo'; import { $isConnected } from 'services/events/stores'; @@ -9,19 +9,13 @@ export const useClearInvocationCache = () => { const { t } = useTranslation(); const { data: cacheStatus } = useGetInvocationCacheStatusQuery(); const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = useClearInvocationCacheMutation({ + const [_trigger, { isLoading }] = useClearInvocationCacheMutation({ fixedCacheKey: 'clearInvocationCache', }); - const isDisabled = useMemo(() => !cacheStatus?.size || !isConnected, [cacheStatus?.size, isConnected]); - - const clearInvocationCache = useCallback(async () => { - if (isDisabled) { - return; - } - + const trigger = useCallback(async () => { try { - await trigger().unwrap(); + await _trigger().unwrap(); toast({ id: 'INVOCATION_CACHE_CLEAR_SUCCEEDED', title: t('invocationCache.clearSucceeded'), @@ -34,7 +28,7 @@ export const useClearInvocationCache = () => { status: 'error', }); } - }, [isDisabled, trigger, t]); + }, [_trigger, t]); - return { clearInvocationCache, isLoading, cacheStatus, isDisabled }; + return { trigger, isLoading, isDisabled: !isConnected || !cacheStatus?.size }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts index 5d5f7713f2..9c24448ed4 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts @@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { listCursorChanged, listPriorityChanged } from 'features/queue/store/queueSlice'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useClearQueueMutation, useGetQueueStatusQuery } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -12,17 +12,17 @@ export const useClearQueue = () => { const dispatch = useAppDispatch(); const { data: queueStatus } = useGetQueueStatusQuery(); const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = useClearQueueMutation({ + const [_trigger, { isLoading }] = useClearQueueMutation({ fixedCacheKey: 'clearQueue', }); - const clearQueue = useCallback(async () => { + const trigger = useCallback(async () => { if (!queueStatus?.queue.total) { return; } try { - await trigger().unwrap(); + await _trigger().unwrap(); toast({ id: 'QUEUE_CLEAR_SUCCEEDED', title: t('queue.clearSucceeded'), @@ -37,14 +37,7 @@ export const useClearQueue = () => { status: 'error', }); } - }, [queueStatus?.queue.total, trigger, dispatch, t]); + }, [queueStatus?.queue.total, _trigger, dispatch, t]); - const isDisabled = useMemo(() => !isConnected || !queueStatus?.queue.total, [isConnected, queueStatus?.queue.total]); - - return { - clearQueue, - isLoading, - queueStatus, - isDisabled, - }; + return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.queue.total }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCurrentDestination.ts b/invokeai/frontend/web/src/features/queue/hooks/useCurrentDestination.ts deleted file mode 100644 index 773d966634..0000000000 --- a/invokeai/frontend/web/src/features/queue/hooks/useCurrentDestination.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useGetCurrentQueueItemQuery } from 'services/api/endpoints/queue'; - -export const useCurrentDestination = () => { - const { destination } = useGetCurrentQueueItemQuery(undefined, { - selectFromResult: ({ data }) => ({ - destination: data ? data.destination : null, - }), - }); - - return destination; -}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCurrentQueueItemId.ts b/invokeai/frontend/web/src/features/queue/hooks/useCurrentQueueItemId.ts new file mode 100644 index 0000000000..daa3a82704 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useCurrentQueueItemId.ts @@ -0,0 +1,11 @@ +import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; + +export const useCurrentQueueItemId = () => { + const { currentQueueItemId } = useGetQueueStatusQuery(undefined, { + selectFromResult: ({ data }) => ({ + currentQueueItemId: data?.queue.item_id ?? null, + }), + }); + + return currentQueueItemId; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts new file mode 100644 index 0000000000..1f34a76d24 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts @@ -0,0 +1,38 @@ +import { useStore } from '@nanostores/react'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDeleteAllExceptCurrentMutation, useGetQueueStatusQuery } from 'services/api/endpoints/queue'; +import { $isConnected } from 'services/events/stores'; + +export const useDeleteAllExceptCurrentQueueItem = () => { + const { t } = useTranslation(); + const { data: queueStatus } = useGetQueueStatusQuery(); + const isConnected = useStore($isConnected); + const [_trigger, { isLoading }] = useDeleteAllExceptCurrentMutation({ + fixedCacheKey: 'deleteAllExceptCurrent', + }); + + const trigger = useCallback(async () => { + if (!queueStatus?.queue.pending) { + return; + } + + try { + await _trigger().unwrap(); + toast({ + id: 'QUEUE_CANCEL_SUCCEEDED', + title: t('queue.cancelSucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'QUEUE_CANCEL_FAILED', + title: t('queue.cancelFailed'), + status: 'error', + }); + } + }, [queueStatus?.queue.pending, _trigger, t]); + + return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.queue.pending } as const; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteCurrentQueueItem.ts new file mode 100644 index 0000000000..d119311235 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteCurrentQueueItem.ts @@ -0,0 +1,20 @@ +import { useCurrentQueueItemId } from 'features/queue/hooks/useCurrentQueueItemId'; +import { useDeleteQueueItem } from 'features/queue/hooks/useDeleteQueueItem'; +import { useCallback } from 'react'; + +export const useDeleteCurrentQueueItem = () => { + const currentQueueItemId = useCurrentQueueItemId(); + const deleteQueueItem = useDeleteQueueItem(); + const trigger = useCallback(() => { + if (currentQueueItemId === null) { + return; + } + deleteQueueItem.trigger(currentQueueItemId); + }, [currentQueueItemId, deleteQueueItem]); + + return { + trigger, + isLoading: deleteQueueItem.isLoading, + isDisabled: deleteQueueItem.isDisabled || currentQueueItemId === null, + }; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts new file mode 100644 index 0000000000..8e37d376b4 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts @@ -0,0 +1,33 @@ +import { useStore } from '@nanostores/react'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDeleteQueueItemMutation } from 'services/api/endpoints/queue'; +import { $isConnected } from 'services/events/stores'; + +export const useDeleteQueueItem = () => { + const isConnected = useStore($isConnected); + const [_trigger, { isLoading }] = useDeleteQueueItemMutation(); + const { t } = useTranslation(); + const trigger = useCallback( + async (item_id: number) => { + try { + await _trigger({ item_id }).unwrap(); + toast({ + id: 'QUEUE_CANCEL_SUCCEEDED', + title: t('queue.cancelSucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'QUEUE_CANCEL_FAILED', + title: t('queue.cancelFailed'), + status: 'error', + }); + } + }, + [t, _trigger] + ); + + return { trigger, isLoading, isDisabled: !isConnected }; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItemsByDestination.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItemsByDestination.ts new file mode 100644 index 0000000000..376063cb44 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItemsByDestination.ts @@ -0,0 +1,33 @@ +import { useStore } from '@nanostores/react'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDeleteQueueItemsByDestinationMutation } from 'services/api/endpoints/queue'; +import { $isConnected } from 'services/events/stores'; + +export const useDeleteQueueItemsByDestination = () => { + const isConnected = useStore($isConnected); + const [_trigger, { isLoading }] = useDeleteQueueItemsByDestinationMutation(); + const { t } = useTranslation(); + const trigger = useCallback( + async (destination: string) => { + try { + await _trigger({ destination }).unwrap(); + toast({ + id: 'QUEUE_CANCEL_SUCCEEDED', + title: t('queue.cancelSucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'QUEUE_CANCEL_FAILED', + title: t('queue.cancelFailed'), + status: 'error', + }); + } + }, + [t, _trigger] + ); + + return { trigger, isLoading, isDisabled: !isConnected }; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDisableInvocationCache.ts b/invokeai/frontend/web/src/features/queue/hooks/useDisableInvocationCache.ts index 703cf8d4cb..17ac6bec44 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useDisableInvocationCache.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useDisableInvocationCache.ts @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useDisableInvocationCacheMutation, useGetInvocationCacheStatusQuery } from 'services/api/endpoints/appInfo'; import { $isConnected } from 'services/events/stores'; @@ -9,22 +9,13 @@ export const useDisableInvocationCache = () => { const { t } = useTranslation(); const { data: cacheStatus } = useGetInvocationCacheStatusQuery(); const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = useDisableInvocationCacheMutation({ + const [_trigger, { isLoading }] = useDisableInvocationCacheMutation({ fixedCacheKey: 'disableInvocationCache', }); - const isDisabled = useMemo( - () => !cacheStatus?.enabled || !isConnected || cacheStatus?.max_size === 0, - [cacheStatus?.enabled, cacheStatus?.max_size, isConnected] - ); - - const disableInvocationCache = useCallback(async () => { - if (isDisabled) { - return; - } - + const trigger = useCallback(async () => { try { - await trigger().unwrap(); + await _trigger().unwrap(); toast({ id: 'INVOCATION_CACHE_DISABLE_SUCCEEDED', title: t('invocationCache.disableSucceeded'), @@ -37,7 +28,12 @@ export const useDisableInvocationCache = () => { status: 'error', }); } - }, [isDisabled, trigger, t]); + }, [_trigger, t]); - return { disableInvocationCache, isLoading, cacheStatus, isDisabled }; + return { + trigger, + isLoading, + cacheStatus, + isDisabled: !cacheStatus?.enabled || !isConnected || cacheStatus?.max_size === 0, + }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnableInvocationCache.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnableInvocationCache.ts index 2589d50717..02fcf444fa 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnableInvocationCache.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnableInvocationCache.ts @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useEnableInvocationCacheMutation, useGetInvocationCacheStatusQuery } from 'services/api/endpoints/appInfo'; import { $isConnected } from 'services/events/stores'; @@ -9,22 +9,13 @@ export const useEnableInvocationCache = () => { const { t } = useTranslation(); const { data: cacheStatus } = useGetInvocationCacheStatusQuery(); const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = useEnableInvocationCacheMutation({ + const [_trigger, { isLoading }] = useEnableInvocationCacheMutation({ fixedCacheKey: 'enableInvocationCache', }); - const isDisabled = useMemo( - () => cacheStatus?.enabled || !isConnected || cacheStatus?.max_size === 0, - [cacheStatus?.enabled, cacheStatus?.max_size, isConnected] - ); - - const enableInvocationCache = useCallback(async () => { - if (isDisabled) { - return; - } - + const trigger = useCallback(async () => { try { - await trigger().unwrap(); + await _trigger().unwrap(); toast({ id: 'INVOCATION_CACHE_ENABLE_SUCCEEDED', title: t('invocationCache.enableSucceeded'), @@ -37,7 +28,11 @@ export const useEnableInvocationCache = () => { status: 'error', }); } - }, [isDisabled, trigger, t]); + }, [_trigger, t]); - return { enableInvocationCache, isLoading, cacheStatus, isDisabled }; + return { + trigger, + isLoading, + isDisabled: cacheStatus?.enabled || !isConnected || cacheStatus?.max_size === 0, + }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts b/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts index d4712ad2b8..9e82576a4f 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetQueueStatusQuery, usePauseProcessorMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -9,18 +9,13 @@ export const usePauseProcessor = () => { const { t } = useTranslation(); const isConnected = useStore($isConnected); const { data: queueStatus } = useGetQueueStatusQuery(); - const [trigger, { isLoading }] = usePauseProcessorMutation({ + const [_trigger, { isLoading }] = usePauseProcessorMutation({ fixedCacheKey: 'pauseProcessor', }); - const isStarted = useMemo(() => Boolean(queueStatus?.processor.is_started), [queueStatus?.processor.is_started]); - - const pauseProcessor = useCallback(async () => { - if (!isStarted) { - return; - } + const trigger = useCallback(async () => { try { - await trigger().unwrap(); + await _trigger().unwrap(); toast({ id: 'PAUSE_SUCCEEDED', title: t('queue.pauseSucceeded'), @@ -33,9 +28,7 @@ export const usePauseProcessor = () => { status: 'error', }); } - }, [isStarted, trigger, t]); + }, [_trigger, t]); - const isDisabled = useMemo(() => !isConnected || !isStarted, [isConnected, isStarted]); - - return { pauseProcessor, isLoading, isStarted, isDisabled }; + return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.processor.is_started }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts b/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts index 09e77e23d6..c186db96df 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts @@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { listCursorChanged, listPriorityChanged } from 'features/queue/store/queueSlice'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetQueueStatusQuery, usePruneQueueMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -11,27 +11,14 @@ export const usePruneQueue = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = usePruneQueueMutation({ + const finishedCount = useFinishedCount(); + const [_trigger, { isLoading }] = usePruneQueueMutation({ fixedCacheKey: 'pruneQueue', }); - const { finishedCount } = useGetQueueStatusQuery(undefined, { - selectFromResult: ({ data }) => { - if (!data) { - return { finishedCount: 0 }; - } - return { - finishedCount: data.queue.completed + data.queue.canceled + data.queue.failed, - }; - }, - }); - - const pruneQueue = useCallback(async () => { - if (!finishedCount) { - return; - } + const trigger = useCallback(async () => { try { - const data = await trigger().unwrap(); + const data = await _trigger().unwrap(); toast({ id: 'PRUNE_SUCCEEDED', title: t('queue.pruneSucceeded', { item_count: data.deleted }), @@ -46,9 +33,23 @@ export const usePruneQueue = () => { status: 'error', }); } - }, [finishedCount, trigger, dispatch, t]); + }, [_trigger, dispatch, t]); - const isDisabled = useMemo(() => !isConnected || !finishedCount, [finishedCount, isConnected]); - - return { pruneQueue, isLoading, finishedCount, isDisabled }; + return { trigger, isLoading, isDisabled: !isConnected || !finishedCount }; +}; + +export const useFinishedCount = () => { + const { finishedCount } = useGetQueueStatusQuery(undefined, { + selectFromResult: ({ data }) => { + if (!data) { + return { finishedCount: 0 }; + } + + return { + finishedCount: data.queue.completed + data.queue.canceled + data.queue.failed, + }; + }, + }); + + return finishedCount; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts b/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts index 058a3b2b3e..baa02ece03 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { toast } from 'features/toast/toast'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetQueueStatusQuery, useResumeProcessorMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -9,18 +9,13 @@ export const useResumeProcessor = () => { const isConnected = useStore($isConnected); const { data: queueStatus } = useGetQueueStatusQuery(); const { t } = useTranslation(); - const [trigger, { isLoading }] = useResumeProcessorMutation({ + const [_trigger, { isLoading }] = useResumeProcessorMutation({ fixedCacheKey: 'resumeProcessor', }); - const isStarted = useMemo(() => Boolean(queueStatus?.processor.is_started), [queueStatus?.processor.is_started]); - - const resumeProcessor = useCallback(async () => { - if (isStarted) { - return; - } + const trigger = useCallback(async () => { try { - await trigger().unwrap(); + await _trigger().unwrap(); toast({ id: 'PROCESSOR_RESUMED', title: t('queue.resumeSucceeded'), @@ -33,9 +28,7 @@ export const useResumeProcessor = () => { status: 'error', }); } - }, [isStarted, trigger, t]); + }, [_trigger, t]); - const isDisabled = useMemo(() => !isConnected || isStarted, [isConnected, isStarted]); - - return { resumeProcessor, isLoading, isStarted, isDisabled }; + return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.processor.is_started }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useRetryQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useRetryQueueItem.ts index cf3442d448..580f88f9ad 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useRetryQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useRetryQueueItem.ts @@ -5,29 +5,32 @@ import { useTranslation } from 'react-i18next'; import { useRetryItemsByIdMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; -export const useRetryQueueItem = (item_id: number) => { +export const useRetryQueueItem = () => { const isConnected = useStore($isConnected); - const [trigger, { isLoading }] = useRetryItemsByIdMutation(); + const [_trigger, { isLoading }] = useRetryItemsByIdMutation(); const { t } = useTranslation(); - const retryQueueItem = useCallback(async () => { - try { - const result = await trigger([item_id]).unwrap(); - if (!result.retried_item_ids.includes(item_id)) { - throw new Error('Failed to retry item'); + const trigger = useCallback( + async (item_id: number) => { + try { + const result = await _trigger([item_id]).unwrap(); + if (!result.retried_item_ids.includes(item_id)) { + throw new Error('Failed to retry item'); + } + toast({ + id: 'QUEUE_RETRY_SUCCEEDED', + title: t('queue.retrySucceeded'), + status: 'success', + }); + } catch { + toast({ + id: 'QUEUE_RETRY_FAILED', + title: t('queue.retryFailed'), + status: 'error', + }); } - toast({ - id: 'QUEUE_RETRY_SUCCEEDED', - title: t('queue.retrySucceeded'), - status: 'success', - }); - } catch { - toast({ - id: 'QUEUE_RETRY_FAILED', - title: t('queue.retryFailed'), - status: 'error', - }); - } - }, [item_id, t, trigger]); + }, + [t, _trigger] + ); - return { retryQueueItem, isLoading, isDisabled: !isConnected }; + return { trigger, isLoading, isDisabled: !isConnected }; }; diff --git a/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx b/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx index 5b688097f3..218ca382b8 100644 --- a/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx +++ b/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx @@ -1,6 +1,5 @@ import { Progress } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useCurrentDestination } from 'features/queue/hooks/useCurrentDestination'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; @@ -8,7 +7,6 @@ import { $isConnected, $lastProgressEvent } from 'services/events/stores'; const ProgressBar = () => { const { t } = useTranslation(); - const destination = useCurrentDestination(); const { data: queueStatus } = useGetQueueStatusQuery(); const isConnected = useStore($isConnected); const lastProgressEvent = useStore($lastProgressEvent); @@ -39,16 +37,6 @@ const ProgressBar = () => { return false; }, [isConnected, lastProgressEvent, queueStatus?.queue.in_progress]); - const colorScheme = useMemo(() => { - if (destination === 'canvas') { - return 'invokeGreen'; - } else if (destination === 'gallery') { - return 'invokeBlue'; - } else { - return 'base'; - } - }, [destination]); - return ( { isIndeterminate={isIndeterminate} h={2} w="full" - colorScheme={colorScheme} + colorScheme="invokeBlue" /> ); }; diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx index 973fb1de87..8630476015 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx @@ -3,9 +3,9 @@ import { useAppSelector } from 'app/store/storeHooks'; import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { useCancelAllExceptCurrentQueueItemDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; import { InvokeButtonTooltip } from 'features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip'; -import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; +import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrentQueueItem'; import { useInvoke } from 'features/queue/hooks/useInvoke'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo } from 'react'; @@ -34,8 +34,8 @@ export const FloatingLeftPanelButtons = memo((props: { onToggle: () => void }) = - - + + ); @@ -103,18 +103,18 @@ const InvokeIconButtonIcon = memo(() => { }); InvokeIconButtonIcon.displayName = 'InvokeIconButtonIcon'; -const CancelCurrentIconButton = memo(() => { +const DeleteCurrentIconButton = memo(() => { const { t } = useTranslation(); - const cancelCurrentQueueItem = useCancelCurrentQueueItem(); + const deleteCurrentQueueItem = useDeleteCurrentQueueItem(); return ( } - onClick={cancelCurrentQueueItem.cancelQueueItem} colorScheme="error" flexGrow={1} /> @@ -122,25 +122,25 @@ const CancelCurrentIconButton = memo(() => { ); }); -CancelCurrentIconButton.displayName = 'CancelCurrentIconButton'; +DeleteCurrentIconButton.displayName = 'DeleteCurrentIconButton'; -const CancelAllExceptCurrentIconButton = memo(() => { +const DeleteAllExceptCurrentIconButton = memo(() => { const { t } = useTranslation(); - const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog(); + const deleteAllExceptCurrent = useDeleteAllExceptCurrentQueueItemDialog(); return ( } colorScheme="error" - onClick={cancelAllExceptCurrent.openDialog} + onClick={deleteAllExceptCurrent.openDialog} flexGrow={1} /> ); }); -CancelAllExceptCurrentIconButton.displayName = 'CancelAllExceptCurrentIconButton'; +DeleteAllExceptCurrentIconButton.displayName = 'DeleteAllExceptCurrentIconButton'; diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index 2239a5df3a..75ff92e58d 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -224,7 +224,7 @@ export const queueApi = api.injectEndpoints({ ]; }, }), - cancelByDestination: build.mutation< + cancelQueueItemsByDestination: build.mutation< paths['/api/v1/queue/{queue_id}/cancel_by_destination']['put']['responses']['200']['content']['application/json'], paths['/api/v1/queue/{queue_id}/cancel_by_destination']['put']['parameters']['query'] >({ @@ -256,6 +256,16 @@ export const queueApi = api.injectEndpoints({ }), invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'], }), + deleteAllExceptCurrent: build.mutation< + paths['/api/v1/queue/{queue_id}/delete_all_except_current']['put']['responses']['200']['content']['application/json'], + void + >({ + query: () => ({ + url: buildQueueUrl('delete_all_except_current'), + method: 'PUT', + }), + invalidatesTags: ['SessionQueueStatus', 'BatchStatus', 'QueueCountsByDestination', 'SessionQueueItem'], + }), retryItemsById: build.mutation< paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['responses']['200']['content']['application/json'], paths['/api/v1/queue/{queue_id}/retry_items_by_id']['put']['requestBody']['content']['application/json'] @@ -329,7 +339,11 @@ export const queueApi = api.injectEndpoints({ url: buildQueueUrl(`i/${item_id}`), method: 'DELETE', }), - invalidatesTags: (result, error, { item_id }) => [{ type: 'SessionQueueItem', id: item_id }], + invalidatesTags: (result, error, { item_id }) => [ + { type: 'SessionQueueItem', id: item_id }, + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ], }), deleteQueueItemsByDestination: build.mutation({ query: ({ destination }) => ({ @@ -366,8 +380,10 @@ export const { useGetQueueStatusQuery, useListQueueItemsQuery, useCancelQueueItemMutation, + useCancelQueueItemsByDestinationMutation, useDeleteQueueItemMutation, useDeleteQueueItemsByDestinationMutation, + useDeleteAllExceptCurrentMutation, useGetBatchStatusQuery, useGetCurrentQueueItemQuery, useGetQueueCountsByDestinationQuery, diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index e167ba36fd..20a5903edb 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1244,6 +1244,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/queue/{queue_id}/delete_all_except_current": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Delete All Except Current + * @description Immediately deletes all queue items except in-processing items + */ + put: operations["delete_all_except_current"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/queue/{queue_id}/cancel_by_batch_ids": { parameters: { query?: never; @@ -5885,6 +5905,17 @@ export type components = { */ type: "dw_openpose_detection"; }; + /** + * DeleteAllExceptCurrentResult + * @description Result of deleting all except current + */ + DeleteAllExceptCurrentResult: { + /** + * Deleted + * @description Number of queue items deleted + */ + deleted: number; + }; /** DeleteBoardResult */ DeleteBoardResult: { /** @@ -24570,6 +24601,38 @@ export interface operations { }; }; }; + delete_all_except_current: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The queue id to perform this operation on */ + queue_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeleteAllExceptCurrentResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; cancel_by_batch_ids: { parameters: { query?: never; From 9df69496e43a63b76975c1c68aae3775ac9b482d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:30:54 +1000 Subject: [PATCH 068/210] feat(ui): remove vary and edit as control buttons --- .../components/SimpleSession/ImageActions.tsx | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx index 96d27b8d14..7e877afce4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx @@ -9,26 +9,6 @@ import type { ImageDTO } from 'services/api/types'; export const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } & ButtonGroupProps) => { const { getState, dispatch } = useAppStore(); - const vary = useCallback(() => { - newCanvasFromImage({ - imageDTO, - type: 'raster_layer', - withResize: true, - getState, - dispatch, - }); - }, [dispatch, getState, imageDTO]); - - const useAsControl = useCallback(() => { - newCanvasFromImage({ - imageDTO, - type: 'control_layer', - withResize: true, - getState, - dispatch, - }); - }, [dispatch, getState, imageDTO]); - const edit = useCallback(() => { newCanvasFromImage({ imageDTO, @@ -40,12 +20,6 @@ export const ImageActions = memo(({ imageDTO, ...rest }: { imageDTO: ImageDTO } }, [dispatch, getState, imageDTO]); return ( - - From 25313663869134cc5c05f524eec7fc90dbf8a434 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:05:02 +1000 Subject: [PATCH 069/210] feat(ui): simple session initial state --- .../components/CanvasMainPanelContent.tsx | 7 +- .../NoSession/GenerateWithControlImage.tsx | 68 ------------------- .../NoSession/GenerateWithStartingImage.tsx | 65 ------------------ ...enerateWithStartingImageAndInpaintMask.tsx | 65 ------------------ .../components/NoSession/NoSession.tsx | 32 --------- .../components/SimpleSession/InitialState.tsx | 50 ++++++++++++++ .../InitialStateAddAStyleReference.tsx | 39 +++++++++++ .../InitialStateCardGridItem.tsx | 24 +++++++ .../InitialStateEditImageCard.tsx | 39 +++++++++++ .../InitialStateGenerateFromText.tsx | 33 +++++++++ .../InitialStateUseALayoutImageCard.tsx | 39 +++++++++++ .../SimpleSession/SimpleSession.tsx | 6 +- .../DeleteAllExceptCurrentButton.tsx | 32 +++++++++ .../DeleteAllExceptCurrentIconButton.tsx | 25 +++++++ .../queue/components/QueueControls.tsx | 4 +- .../components/QueueTabQueueControls.tsx | 5 +- 16 files changed, 291 insertions(+), 242 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImage.tsx delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithStartingImageAndInpaintMask.tsx delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/NoSession/NoSession.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx create mode 100644 invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentButton.tsx create mode 100644 invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentIconButton.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index 242e3e363c..b42a2f716a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -1,6 +1,5 @@ import { useAppSelector } from 'app/store/storeHooks'; import { AdvancedSession } from 'features/controlLayers/components/AdvancedSession/AdvancedSession'; -import { NoSession } from 'features/controlLayers/components/NoSession/NoSession'; import { SimpleSession } from 'features/controlLayers/components/SimpleSession/SimpleSession'; import { selectCanvasSessionId, selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { memo } from 'react'; @@ -12,11 +11,7 @@ export const CanvasMainPanelContent = memo(() => { const id = useAppSelector(selectCanvasSessionId); if (type === 'simple') { - if (id === null) { - return ; - } else { - return ; - } + return ; } if (type === 'advanced') { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx deleted file mode 100644 index 14b253330c..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/NoSession/GenerateWithControlImage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable i18next/no-literal-string */ - -import { Button, Flex, Text } from '@invoke-ai/ui-library'; -import { useAppStore } from 'app/store/nanostores/store'; -import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; -import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import { newCanvasFromImage } from 'features/imageActions/actions'; -import { memo, useMemo } from 'react'; -import { Trans } from 'react-i18next'; -import { PiUploadBold } from 'react-icons/pi'; -import type { ImageDTO } from 'services/api/types'; -import type { Param0 } from 'tsafe'; - -const generateWithControlImageDndTargetData = newCanvasFromImageDndTarget.getData({ - type: 'control_layer', - withResize: true, -}); - -export const GenerateWithControlImage = memo(() => { - const { getState, dispatch } = useAppStore(); - const useImageUploadButtonOptions = useMemo>( - () => ({ - onUpload: (imageDTO: ImageDTO) => { - newCanvasFromImage({ imageDTO, type: 'control_layer', withResize: true, getState, dispatch }); - }, - allowMultiple: false, - }), - [dispatch, getState] - ); - const uploadApi = useImageUploadButton(useImageUploadButtonOptions); - const components = useMemo( - () => ({ - UploadButton: ( - - or - - - - - - - ); -}); -NoSession.displayName = 'NoSession'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx new file mode 100644 index 0000000000..31dc291cda --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx @@ -0,0 +1,50 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { InitialStateAddAStyleReference } from 'features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference'; +import { InitialStateCardGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateCardGridItem'; +import { InitialStateEditImageCard } from 'features/controlLayers/components/SimpleSession/InitialStateEditImageCard'; +import { InitialStateGenerateFromText } from 'features/controlLayers/components/SimpleSession/InitialStateGenerateFromText'; +import { InitialStateUseALayoutImageCard } from 'features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { memo, useCallback } from 'react'; + +export const InitialState = memo(() => { + const dispatch = useAppDispatch(); + const newCanvasSession = useCallback(() => { + dispatch(canvasSessionTypeChanged({ type: 'advanced' })); + }, [dispatch]); + + return ( + + Choose a starting method. + + Drag an image onto a card or click the upload icon. + + + + + + + + + + + + + + + + + + + or{' '} + + + + ); +}); +InitialState.displayName = 'InitialState'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx new file mode 100644 index 0000000000..f41ad61ffa --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx @@ -0,0 +1,39 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { newCanvasFromImage } from 'features/imageActions/actions'; +import { memo, useCallback } from 'react'; +import { PiUserCircleGearBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +const NEW_CANVAS_OPTIONS = { type: 'reference_image' } as const; + +const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS); + +export const InitialStateAddAStyleReference = memo(() => { + const { getState, dispatch } = useAppStore(); + + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS }); + }, + [dispatch, getState] + ); + + return ( + <> + + Add a Style Reference + Add an image to transfer its look. + + + + + + ); +}); +InitialStateAddAStyleReference.displayName = 'InitialStateAddAStyleReference'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx new file mode 100644 index 0000000000..708b005db1 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx @@ -0,0 +1,24 @@ +import { GridItem } from '@invoke-ai/ui-library'; +import { memo, type PropsWithChildren } from 'react'; + +export const InitialStateCardGridItem = memo((props: PropsWithChildren) => { + return ( + + {props.children} + + ); +}); + +InitialStateCardGridItem.displayName = 'InitialStateCardGridItem'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx new file mode 100644 index 0000000000..d0c7d0032c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx @@ -0,0 +1,39 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { newCanvasFromImage } from 'features/imageActions/actions'; +import { memo, useCallback } from 'react'; +import { PiPencilBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +const NEW_CANVAS_OPTIONS = { type: 'raster_layer', withInpaintMask: true } as const; + +const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS); + +export const InitialStateEditImageCard = memo(() => { + const { getState, dispatch } = useAppStore(); + + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS }); + }, + [dispatch, getState] + ); + + return ( + <> + + Edit Image + Add an image to refine. + + + + + + ); +}); +InitialStateEditImageCard.displayName = 'InitialStateEditImageCard'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx new file mode 100644 index 0000000000..6d4fa5a2cf --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx @@ -0,0 +1,33 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Flex, Heading, Icon, IconButton, Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi'; + +const focusOnPrompt = () => { + const promptElement = document.getElementById('prompt'); + if (promptElement instanceof HTMLTextAreaElement) { + promptElement.focus(); + promptElement.select(); + } +}; + +export const InitialStateGenerateFromText = memo(() => { + return ( + <> + + Generate from Text + Enter a prompt and Invoke. + + } + variant="link" + h={8} + /> + + + ); +}); +InitialStateGenerateFromText.displayName = 'InitialStateGenerateFromText'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx new file mode 100644 index 0000000000..54d2ef6f75 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx @@ -0,0 +1,39 @@ +/* eslint-disable i18next/no-literal-string */ + +import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { newCanvasFromImage } from 'features/imageActions/actions'; +import { memo, useCallback } from 'react'; +import { PiRectangleDashedBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +const NEW_CANVAS_OPTIONS = { type: 'control_layer', withResize: true } as const; + +const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS); + +export const InitialStateUseALayoutImageCard = memo(() => { + const { getState, dispatch } = useAppStore(); + + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS }); + }, + [dispatch, getState] + ); + + return ( + <> + + Use a Layout Image + Add an image to control composition. + + + + + + ); +}); +InitialStateUseALayoutImageCard.displayName = 'InitialStateUseALayoutImageCard'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx index 621bf95141..750066526e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx @@ -1,8 +1,12 @@ import { CanvasSessionContextProvider } from 'features/controlLayers/components/SimpleSession/context'; +import { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState'; import { StagingArea } from 'features/controlLayers/components/SimpleSession/StagingArea'; import { memo } from 'react'; -export const SimpleSession = memo(({ id }: { id: string }) => { +export const SimpleSession = memo(({ id }: { id: string | null }) => { + if (id === null) { + return ; + } return ( diff --git a/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentButton.tsx b/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentButton.tsx new file mode 100644 index 0000000000..a3ffd7d519 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentButton.tsx @@ -0,0 +1,32 @@ +import type { ButtonProps } from '@invoke-ai/ui-library'; +import { Button } from '@invoke-ai/ui-library'; +import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiXCircle } from 'react-icons/pi'; + +type Props = ButtonProps; + +export const DeleteAllExceptCurrentButton = memo((props: Props) => { + const { t } = useTranslation(); + const deleteAllExceptCurrent = useDeleteAllExceptCurrentQueueItemDialog(); + + return ( + <> + + + ); +}); + +DeleteAllExceptCurrentButton.displayName = 'DeleteAllExceptCurrentButton'; diff --git a/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentIconButton.tsx b/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentIconButton.tsx new file mode 100644 index 0000000000..6cf1b8f435 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/DeleteAllExceptCurrentIconButton.tsx @@ -0,0 +1,25 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiXCircle } from 'react-icons/pi'; + +export const DeleteAllExceptCurrentIconButton = memo(() => { + const { t } = useTranslation(); + const deleteAllExceptCurrent = useDeleteAllExceptCurrentQueueItemDialog(); + + return ( + } + colorScheme="error" + onClick={deleteAllExceptCurrent.openDialog} + /> + ); +}); + +DeleteAllExceptCurrentIconButton.displayName = 'DeleteAllExceptCurrentIconButton'; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx b/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx index 040ecc6de9..52fb354d87 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueControls.tsx @@ -1,5 +1,5 @@ import { Flex, Spacer, useShiftModifier } from '@invoke-ai/ui-library'; -import { DeleteAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { DeleteAllExceptCurrentIconButton } from 'features/queue/components/DeleteAllExceptCurrentIconButton'; import { DeleteCurrentQueueItemIconButton } from 'features/queue/components/DeleteCurrentQueueItemIconButton'; import { QueueActionsMenuButton } from 'features/queue/components/QueueActionsMenuButton'; import ProgressBar from 'features/system/components/ProgressBar'; @@ -30,7 +30,7 @@ export const DeleteIconButton = memo(() => { return ; } - return ; + return ; }); DeleteIconButton.displayName = 'DeleteIconButton'; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx b/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx index 6432b4e913..303687007e 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueTabQueueControls.tsx @@ -1,6 +1,5 @@ -/* eslint-disable i18next/no-literal-string */ import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; -import { DeleteAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog'; +import { DeleteAllExceptCurrentButton } from 'features/queue/components/DeleteAllExceptCurrentButton'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react'; @@ -24,7 +23,7 @@ const QueueTabQueueControls = () => { )} - +
From 9f392c8c3ce1e5b68d6e63436a46025e8d2ed78a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:06:48 +1000 Subject: [PATCH 070/210] feat(ui): remove technical progress message from full preview --- .../components/SimpleSession/QueueItemPreviewFull.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx index 11a3904bd9..029ad29454 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx @@ -4,7 +4,6 @@ import { ImageActions } from 'features/controlLayers/components/SimpleSession/Im 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 { QueueItemProgressMessage } from 'features/controlLayers/components/SimpleSession/QueueItemProgressMessage'; import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; import { getQueueItemElementId, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/shared'; import { DndImage } from 'features/dnd/DndImage'; @@ -41,8 +40,14 @@ export const QueueItemPreviewFull = memo(({ item, number }: Props) => { {!imageLoaded && } {imageDTO && } - - +
); }); From e81dde0933f9280cf0bc82259e3c0e1041eb7401 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:09:23 +1000 Subject: [PATCH 071/210] feat(ui): fiddle w/ staging area header --- .../components/SimpleSession/StagingAreaHeader.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx index 4e265a5e18..82bc9c9009 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -1,5 +1,5 @@ /* eslint-disable i18next/no-literal-string */ -import { Button, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library'; +import { Button, Divider, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; @@ -24,16 +24,17 @@ export const StagingAreaHeader = memo(() => { ); return ( - + - Generations + Staging Area Auto-switch - From c316f07fb215394060dfe8c89d63d1b291ef824b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:16:56 +1000 Subject: [PATCH 072/210] feat(ui): add startover button to canvas toolbar --- .../SimpleSession/StagingAreaHeader.tsx | 18 +++++------------ .../components/StartOverButton.tsx | 20 +++++++++++++++++++ .../components/Toolbar/CanvasToolbar.tsx | 5 ++++- 3 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx index 82bc9c9009..bb50398eb9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -1,20 +1,14 @@ /* eslint-disable i18next/no-literal-string */ -import { Button, Divider, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library'; +import { Divider, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; -import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { StartOverButton } from 'features/controlLayers/components/StartOverButton'; import type { ChangeEvent } from 'react'; import { memo, useCallback } from 'react'; export const StagingAreaHeader = memo(() => { const ctx = useCanvasSessionContext(); const autoSwitch = useStore(ctx.$autoSwitch); - const dispatch = useAppDispatch(); - - const startOver = useCallback(() => { - dispatch(canvasSessionTypeChanged({ type: 'simple' })); - }, [dispatch]); const onChangeAutoSwitch = useCallback( (e: ChangeEvent) => { @@ -24,19 +18,17 @@ export const StagingAreaHeader = memo(() => { ); return ( - + Staging Area - + Auto-switch - + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx new file mode 100644 index 0000000000..4205f772a3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx @@ -0,0 +1,20 @@ +/* eslint-disable i18next/no-literal-string */ +import { Button } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { memo, useCallback } from 'react'; + +export const StartOverButton = memo(() => { + const dispatch = useAppDispatch(); + + const startOver = useCallback(() => { + dispatch(canvasSessionTypeChanged({ type: 'simple' })); + }, [dispatch]); + + return ( + + ); +}); +StartOverButton.displayName = 'StartOverButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index c2c4b5451f..3b7b231875 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -1,6 +1,7 @@ /* eslint-disable i18next/no-literal-string */ import { Divider, Flex, Spacer } from '@invoke-ai/ui-library'; import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover'; +import { StartOverButton } from 'features/controlLayers/components/StartOverButton'; import { ToolColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker'; import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings'; import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton'; @@ -29,7 +30,7 @@ export const CanvasToolbar = memo(() => { useCanvasFilterHotkey(); return ( - + @@ -46,6 +47,8 @@ export const CanvasToolbar = memo(() => { + + ); }); From ac206f476778a19898bc2a48fc4137cfd8cdd70b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:35:14 +1000 Subject: [PATCH 073/210] feat(ui): make main panel styling and title consistent --- .../AdvancedSession/AdvancedSession.tsx | 3 +- .../components/SimpleSession/InitialState.tsx | 58 ++++++++++--------- .../SimpleSession/StagingAreaHeader.tsx | 6 +- .../components/Tool/ToolBrushWidth.tsx | 4 -- .../components/Tool/ToolEraserWidth.tsx | 4 -- .../components/Toolbar/CanvasToolbar.tsx | 9 +-- 6 files changed, 41 insertions(+), 43 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx index 0155f52c06..88fc045919 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx @@ -1,6 +1,6 @@ /* eslint-disable i18next/no-literal-string */ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { ContextMenu, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { ContextMenu, Divider, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; @@ -77,6 +77,7 @@ export const AdvancedSession = memo(({ id }: { id: string | null }) => { + renderMenu={renderMenu} withLongPress={false}> {(ref) => ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx index 31dc291cda..25bace52b6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx @@ -1,6 +1,6 @@ /* eslint-disable i18next/no-literal-string */ -import { Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; +import { Button, Divider, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { InitialStateAddAStyleReference } from 'features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference'; import { InitialStateCardGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateCardGridItem'; @@ -17,33 +17,39 @@ export const InitialState = memo(() => { }, [dispatch]); return ( - - Choose a starting method. - - Drag an image onto a card or click the upload icon. - + + + Get Started + + + + Choose a starting method. + + Drag an image onto a card or click the upload icon. + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - or{' '} - - + + or{' '} + + + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx index bb50398eb9..b048d7dd59 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -1,5 +1,5 @@ /* eslint-disable i18next/no-literal-string */ -import { Divider, Flex, FormControl, FormLabel, Spacer, Switch, Text } from '@invoke-ai/ui-library'; +import { Divider, Flex, FormControl, FormLabel, Heading, Spacer, Switch } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { StartOverButton } from 'features/controlLayers/components/StartOverButton'; @@ -19,9 +19,7 @@ export const StagingAreaHeader = memo(() => { return ( - - Staging Area - + Review Session Auto-switch diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx index 6d14493d50..a1d827ca36 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBrushWidth.tsx @@ -1,7 +1,6 @@ import { CompositeSlider, FormControl, - FormLabel, IconButton, NumberInput, NumberInputField, @@ -20,7 +19,6 @@ import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/us import { clamp } from 'lodash-es'; import type { KeyboardEvent } from 'react'; import { memo, useCallback, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; const selectBrushWidth = createSelector(selectCanvasSettingsSlice, (settings) => settings.brushWidth); @@ -67,7 +65,6 @@ const sliderDefaultValue = mapRawValueToSliderValue(50); export const ToolBrushWidth = memo(() => { const dispatch = useAppDispatch(); - const { t } = useTranslation(); const isSelected = useToolIsSelected('brush'); const width = useAppSelector(selectBrushWidth); const [localValue, setLocalValue] = useState(width); @@ -145,7 +142,6 @@ export const ToolBrushWidth = memo(() => { return ( - {t('controlLayers.width')} settings.eraserWidth); @@ -70,7 +68,6 @@ const sliderDefaultValue = mapRawValueToSliderValue(50); export const ToolEraserWidth = memo(() => { const dispatch = useAppDispatch(); - const { t } = useTranslation(); const isSelected = useToolIsSelected('eraser'); const width = useAppSelector(selectEraserWidth); const [localValue, setLocalValue] = useState(width); @@ -148,7 +145,6 @@ export const ToolEraserWidth = memo(() => { return ( - {t('controlLayers.width')} { useCanvasFilterHotkey(); return ( - + + Canvas + - - + From 6754fde935f93269de5d1f43802b7431f284c32d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:39:40 +1000 Subject: [PATCH 074/210] chore(ui): lint --- .../controlLayers/components/Toolbar/CanvasToolbar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index c329eabe24..8939df125b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -31,7 +31,9 @@ export const CanvasToolbar = memo(() => { return ( - Canvas + + Canvas + From 2f26657c17dfc4241e4244a2d00dc4f86bdca1b2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 10 Jun 2025 12:16:33 +1000 Subject: [PATCH 075/210] feat(ui): move canvas-specific staging subscriptions to CanvasStagingAreaModule --- .../SimpleSession/StagingAreaItemsList.tsx | 27 +--------- .../components/SimpleSession/context.tsx | 54 +++++++++++++++---- .../konva/CanvasStagingAreaModule.ts | 27 +++++++++- 3 files changed, 71 insertions(+), 37 deletions(-) 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 c195fff0b1..36c6d21ec8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx @@ -4,9 +4,7 @@ import { useStore } from '@nanostores/react'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { QueueItemPreviewMini } from 'features/controlLayers/components/SimpleSession/QueueItemPreviewMini'; -import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { effect } from 'nanostores'; import { memo, useEffect } from 'react'; export const StagingAreaItemsList = memo(() => { @@ -20,29 +18,8 @@ export const StagingAreaItemsList = memo(() => { return; } - return effect([ctx.$selectedItem, ctx.$progressData], (selectedItem, progressData) => { - if (!selectedItem) { - canvasManager.stagingArea.$imageSrc.set(null); - return; - } - - const outputImageName = getOutputImageName(selectedItem); - - if (outputImageName) { - canvasManager.stagingArea.$imageSrc.set({ type: 'imageName', data: outputImageName }); - return; - } - - const data = progressData[selectedItem.item_id]; - - if (data?.progressImage) { - canvasManager.stagingArea.$imageSrc.set({ type: 'dataURL', data: data.progressImage.dataURL }); - return; - } - - canvasManager.stagingArea.$imageSrc.set(null); - }); - }, [canvasManager, ctx.$progressData, ctx.$selectedItem]); + return canvasManager.stagingArea.connectToSession(ctx.$selectedItemId, ctx.$progressData); + }, [canvasManager, ctx.$progressData, ctx.$selectedItemId]); return ( 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 daf843f947..172a8188ce 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -13,18 +13,26 @@ import type { S } from 'services/api/types'; import { $socket } from 'services/events/stores'; import { assert } from 'tsafe'; -type ProgressData = { +export type ProgressData = { itemId: number; progressEvent: S['InvocationProgressEvent'] | null; progressImage: ProgressImage | null; + outputImageName: string | null; }; +const getInitialProgressData = (itemId: number): ProgressData => ({ + itemId, + progressEvent: null, + progressImage: null, + outputImageName: null, +}); + export const useProgressData = ( $progressData: WritableAtom>, itemId: number ): ProgressData => { const [value, setValue] = useState(() => { - return $progressData.get()[itemId] ?? { itemId, progressEvent: null, progressImage: null }; + return $progressData.get()[itemId] ?? getInitialProgressData(itemId); }); useEffect(() => { const unsub = $progressData.subscribe((data) => { @@ -62,6 +70,7 @@ const setProgress = ($progressData: WritableAtom>, itemId: data.item_id, progressEvent: data, progressImage: data.image ?? null, + outputImageName: null, }, }); } @@ -315,18 +324,45 @@ export const CanvasSessionContextProvider = memo( const progressData = $progressData.get(); const toDelete: number[] = []; - const toClear: number[] = []; + const toUpdate: ProgressData[] = []; for (const datum of Object.values(progressData)) { const item = items.find(({ item_id }) => item_id === datum.itemId); if (!item) { toDelete.push(datum.itemId); } else if (item.status === 'canceled' || item.status === 'failed') { - toClear.push(datum.itemId); + toUpdate[datum.itemId] = { + ...datum, + progressEvent: null, + progressImage: null, + outputImageName: null, + }; } } - if (toDelete.length === 0) { + for (const item of items) { + const datum = progressData[item.item_id]; + + if (datum) { + if (datum.outputImageName) { + continue; + } + const outputImageName = getOutputImageName(item); + if (!outputImageName) { + continue; + } + toUpdate.push({ + ...datum, + outputImageName, + }); + } else { + const _datum = getInitialProgressData(item.item_id); + _datum.outputImageName = getOutputImageName(item); + toUpdate.push(_datum); + } + } + + if (toDelete.length === 0 && toUpdate.length === 0) { return; } @@ -336,12 +372,8 @@ export const CanvasSessionContextProvider = memo( delete newProgressData[itemId]; } - for (const itemId of toClear) { - const current = newProgressData[itemId]; - if (current) { - current.progressEvent = null; - current.progressImage = null; - } + for (const datum of toUpdate) { + newProgressData[datum.itemId] = datum; } $progressData.set(newProgressData); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index 52cbc54611..fa3ca84ec6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts @@ -1,4 +1,5 @@ import { Mutex } from 'async-mutex'; +import type { ProgressData } from 'features/controlLayers/components/SimpleSession/context'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; @@ -6,7 +7,8 @@ import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { CanvasImageState } from 'features/controlLayers/store/types'; import Konva from 'konva'; -import { atom } from 'nanostores'; +import type { Atom, WritableAtom } from 'nanostores'; +import { atom, effect } from 'nanostores'; import type { Logger } from 'roarr'; type ImageNameSrc = { type: 'imageName'; data: string }; @@ -133,6 +135,29 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging)); }; + connectToSession = ( + $selectedItemId: Atom, + $progressData: WritableAtom> + ) => + effect([$selectedItemId, $progressData], (selectedItemId, progressData) => { + if (!selectedItemId) { + this.$imageSrc.set(null); + return; + } + + const datum = progressData[selectedItemId]; + + if (datum?.outputImageName) { + this.$imageSrc.set({ type: 'imageName', data: datum.outputImageName }); + return; + } else if (datum?.progressImage) { + this.$imageSrc.set({ type: 'dataURL', data: datum.progressImage.dataURL }); + return; + } else { + this.$imageSrc.set(null); + } + }); + private _getImageFromSrc = ( { type, data }: ImageNameSrc | DataURLSrc, width: number, From 711fe91b24d89a7609f2359f4e112f306416e4e3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 10 Jun 2025 12:42:07 +1000 Subject: [PATCH 076/210] feat(ui): add on first progress autoswitch mode --- .../SimpleSession/QueueItemPreviewMini.tsx | 12 +-- .../SimpleSession/StagingAreaHeader.tsx | 22 +----- .../components/SimpleSession/context.tsx | 23 ++++-- .../StagingArea/SimpleStagingAreaToolbar.tsx | 2 + .../SimpleStagingAreaToolbarMenu.tsx | 17 +++++ .../StagingArea/StagingAreaToolbar.tsx | 4 +- .../StagingArea/StagingAreaToolbarMenu.tsx | 20 +++++ .../StagingAreaToolbarMenuAutoSwitch.tsx | 34 +++++++++ ...agingAreaToolbarMenuNewLayerFromImage.tsx} | 75 ++++++++----------- 9 files changed, 125 insertions(+), 84 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbarMenu.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx rename invokeai/frontend/web/src/features/controlLayers/components/StagingArea/{StagingAreaToolbarSaveAsMenu.tsx => StagingAreaToolbarMenuNewLayerFromImage.tsx} (68%) 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 3393d7a18e..5851178b0f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -42,10 +42,6 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = ctx.$selectedItemId.set(item.item_id); }, [ctx.$selectedItemId, item.item_id]); - const onDoubleClick = useCallback(() => { - ctx.$autoSwitch.set(item.status === 'in_progress'); - }, [ctx.$autoSwitch, item.status]); - const onLoad = useCallback(() => { setImageLoaded(true); if (ctx.$progressData.get()[item.item_id]) { @@ -54,13 +50,7 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = }, [ctx.$lastLoadedItemId, ctx.$progressData, item.item_id]); return ( - + {imageDTO && } {!imageLoaded && } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx index b048d7dd59..0f4828c93f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -1,31 +1,13 @@ /* eslint-disable i18next/no-literal-string */ -import { Divider, Flex, FormControl, FormLabel, Heading, Spacer, Switch } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { Flex, Heading, Spacer } from '@invoke-ai/ui-library'; import { StartOverButton } from 'features/controlLayers/components/StartOverButton'; -import type { ChangeEvent } from 'react'; -import { memo, useCallback } from 'react'; +import { memo } from 'react'; export const StagingAreaHeader = memo(() => { - const ctx = useCanvasSessionContext(); - const autoSwitch = useStore(ctx.$autoSwitch); - - const onChangeAutoSwitch = useCallback( - (e: ChangeEvent) => { - ctx.$autoSwitch.set(e.target.checked); - }, - [ctx.$autoSwitch] - ); - return ( Review Session - - Auto-switch - - - ); 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 172a8188ce..3f7a608b7b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -12,6 +12,10 @@ 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 { z } from 'zod'; + +export const zAutoSwitchMode = z.enum(['off', 'first_progress', 'completed']); +export type AutoSwitchMode = z.infer; export type ProgressData = { itemId: number; @@ -86,7 +90,7 @@ type CanvasSessionContextValue = { $selectedItem: Atom; $selectedItemIndex: Atom; $selectedItemOutputImageName: Atom; - $autoSwitch: WritableAtom; + $autoSwitch: WritableAtom; $lastLoadedItemId: WritableAtom; selectNext: () => void; selectPrev: () => void; @@ -121,7 +125,7 @@ export const CanvasSessionContextProvider = memo( /** * Whether auto-switch is enabled. */ - const $autoSwitch = useState(() => atom(true))[0]; + const $autoSwitch = useState(() => atom('first_progress'))[0]; /** * An internal flag used to work around race conditions with auto-switch switching to queue items before their @@ -184,15 +188,15 @@ export const CanvasSessionContextProvider = memo( * image recorded. */ const $selectedItemOutputImageName = useState(() => - computed([$selectedItem], (selectedItem) => { - if (selectedItem === null) { + computed([$selectedItemId, $progressData], (selectedItemId, progressData) => { + if (selectedItemId === null) { return null; } - const outputImageName = getOutputImageName(selectedItem); - if (outputImageName === null) { + const datum = progressData[selectedItemId]; + if (!datum) { return null; } - return outputImageName; + return datum.outputImageName; }) )[0]; @@ -269,6 +273,9 @@ export const CanvasSessionContextProvider = memo( return; } setProgress($progressData, data); + if ($autoSwitch.get() === 'first_progress') { + $selectedItemId.set(data.item_id); + } }; socket.on('invocation_progress', onProgress); @@ -391,7 +398,7 @@ export const CanvasSessionContextProvider = memo( if (lastLoadedItemId === null) { return; } - if ($autoSwitch.get()) { + if ($autoSwitch.get() === 'completed') { $selectedItemId.set(lastLoadedItemId); } $lastLoadedItemId.set(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx index 26511981f3..3fa5e3e0ca 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbar.tsx @@ -1,4 +1,5 @@ import { ButtonGroup } from '@invoke-ai/ui-library'; +import { SimpleStagingAreaToolbarMenu } from 'features/controlLayers/components/StagingArea/SimpleStagingAreaToolbarMenu'; import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton'; @@ -16,6 +17,7 @@ export const SimpleStagingAreaToolbar = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbarMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbarMenu.tsx new file mode 100644 index 0000000000..60b3a39203 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/SimpleStagingAreaToolbarMenu.tsx @@ -0,0 +1,17 @@ +import { IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { StagingAreaToolbarMenuAutoSwitch } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch'; +import { memo } from 'react'; +import { PiDotsThreeBold } from 'react-icons/pi'; + +export const SimpleStagingAreaToolbarMenu = memo(() => { + return ( + + } colorScheme="invokeBlue" /> + + + + + ); +}); + +SimpleStagingAreaToolbarMenu.displayName = 'SimpleStagingAreaToolbarMenu'; 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 63926e766d..6b57e5cf93 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbar.tsx @@ -6,9 +6,9 @@ import { StagingAreaToolbarAcceptButton } from 'features/controlLayers/component import { StagingAreaToolbarDiscardAllButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardAllButton'; import { StagingAreaToolbarDiscardSelectedButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton'; import { StagingAreaToolbarImageCountButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarImageCountButton'; +import { StagingAreaToolbarMenu } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenu'; import { StagingAreaToolbarNextButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarNextButton'; import { StagingAreaToolbarPrevButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarPrevButton'; -import { StagingAreaToolbarSaveAsMenu } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu'; import { StagingAreaToolbarSaveSelectedToGalleryButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton'; import { StagingAreaToolbarToggleShowResultsButton } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarToggleShowResultsButton'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; @@ -42,7 +42,7 @@ export const StagingAreaToolbar = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx new file mode 100644 index 0000000000..73710521da --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenu.tsx @@ -0,0 +1,20 @@ +import { IconButton, Menu, MenuButton, MenuDivider, MenuList } from '@invoke-ai/ui-library'; +import { StagingAreaToolbarMenuAutoSwitch } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch'; +import { StagingAreaToolbarNewLayerFromImageMenuItems } from 'features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage'; +import { memo } from 'react'; +import { PiDotsThreeBold } from 'react-icons/pi'; + +export const StagingAreaToolbarMenu = memo(() => { + return ( + + } colorScheme="invokeBlue" /> + + + + + + + ); +}); + +StagingAreaToolbarMenu.displayName = 'StagingAreaToolbarMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx new file mode 100644 index 0000000000..567e6beb5e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx @@ -0,0 +1,34 @@ +/* eslint-disable i18next/no-literal-string */ +import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useCanvasSessionContext, zAutoSwitchMode } from 'features/controlLayers/components/SimpleSession/context'; +import { memo, useCallback } from 'react'; + +export const StagingAreaToolbarMenuAutoSwitch = memo(() => { + const ctx = useCanvasSessionContext(); + const autoSwitch = useStore(ctx.$autoSwitch); + + const onChange = useCallback( + (val: string | string[]) => { + const newAutoSwitch = zAutoSwitchMode.parse(val); + ctx.$autoSwitch.set(newAutoSwitch); + }, + [ctx.$autoSwitch] + ); + + return ( + + + Off + + + First Progress + + + Completed + + + ); +}); + +StagingAreaToolbarMenuAutoSwitch.displayName = 'StagingAreaToolbarMenuAutoSwitch'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx similarity index 68% rename from invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx index 455eb97319..54ab1578db 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveAsMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx @@ -1,4 +1,4 @@ -import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { MenuGroup, MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/nanostores/store'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; @@ -8,12 +8,11 @@ import { createNewCanvasEntityFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiDotsThreeBold } from 'react-icons/pi'; import { copyImage } from 'services/api/endpoints/images'; const uploadImageArg = { image_category: 'general', is_intermediate: true, silent: true } as const; -export const StagingAreaToolbarSaveAsMenu = memo(() => { +export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { const canvasManager = useCanvasManager(); const { t } = useTranslation(); const ctx = useCanvasSessionContext(); @@ -97,47 +96,37 @@ export const StagingAreaToolbarSaveAsMenu = memo(() => { }, [imageName, store, toastSentToCanvas]); return ( - - } - colorScheme="invokeBlue" + + } + onClickCapture={onClickNewInpaintMaskFromImage} isDisabled={!imageName || !shouldShowStagedImage} - /> - - } - onClickCapture={onClickNewInpaintMaskFromImage} - isDisabled={!imageName || !shouldShowStagedImage} - > - {t('controlLayers.inpaintMask')} - - } - onClickCapture={onClickNewRegionalGuidanceFromImage} - isDisabled={!imageName || !shouldShowStagedImage} - > - {t('controlLayers.regionalGuidance')} - - } - onClickCapture={onClickNewControlLayerFromImage} - isDisabled={!imageName || !shouldShowStagedImage} - > - {t('controlLayers.controlLayer')} - - } - onClickCapture={onClickNewRasterLayerFromImage} - isDisabled={!imageName || !shouldShowStagedImage} - > - {t('controlLayers.rasterLayer')} - - - + > + {t('controlLayers.inpaintMask')} + + } + onClickCapture={onClickNewRegionalGuidanceFromImage} + isDisabled={!imageName || !shouldShowStagedImage} + > + {t('controlLayers.regionalGuidance')} + + } + onClickCapture={onClickNewControlLayerFromImage} + isDisabled={!imageName || !shouldShowStagedImage} + > + {t('controlLayers.controlLayer')} + + } + onClickCapture={onClickNewRasterLayerFromImage} + isDisabled={!imageName || !shouldShowStagedImage} + > + {t('controlLayers.rasterLayer')} + +
); }); -StagingAreaToolbarSaveAsMenu.displayName = 'StagingAreaToolbarSaveAsMenu'; +StagingAreaToolbarNewLayerFromImageMenuItems.displayName = 'StagingAreaToolbarNewLayerFromImageMenuItems'; From d640a9001b42bd1c65ddf93ac36683054c0e2a59 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:59:08 +1000 Subject: [PATCH 077/210] fix(ui): switch only on first progress image --- .../controlLayers/components/SimpleSession/context.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 3f7a608b7b..f89d76fe8d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -272,8 +272,9 @@ export const CanvasSessionContextProvider = memo( if (data.destination !== session.id) { return; } + const isFirstProgressImage = !$progressData.get()[data.item_id]?.progressImage && !!data.image; setProgress($progressData, data); - if ($autoSwitch.get() === 'first_progress') { + if ($autoSwitch.get() === 'first_progress' && isFirstProgressImage) { $selectedItemId.set(data.item_id); } }; From 94afc13813fe86680b8aff24d0fe310e0834ecd3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 10 Jun 2025 18:15:26 +1000 Subject: [PATCH 078/210] feat(ui): close viewer on escape --- .gitignore | 1 + .../gallery/components/ImageViewer/ImageViewer.tsx | 10 ++++++++++ invokeai/version/invokeai_version.py | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8c1ccbddde..fba699d5e9 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,7 @@ cython_debug/ # Scratch folder .scratch/ .vscode/ +.zed/ # source installer files installer/*zip diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 521c0a2486..decb9a2c78 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -73,6 +73,16 @@ export const ImageViewerModal = memo(() => { handler: imageViewer.close, }); + useHotkeys( + 'esc', + imageViewer.close, + { + preventDefault: true, + enabled: imageViewer.isOpen, + }, + [imageViewer.isOpen] + ); + return ( Date: Wed, 11 Jun 2025 11:39:44 +1000 Subject: [PATCH 079/210] feat(ui): add AppGetState type --- invokeai/frontend/web/src/app/store/store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index a65416924d..32f386fc44 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -210,3 +210,4 @@ export type RootState = ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AppThunkDispatch = ThunkDispatch; export type AppDispatch = ReturnType['dispatch']; +export type AppGetState = ReturnType['getState']; From 01784fb3bf2da1cb9ff5082b382ef0cc1581ffb3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:44:31 +1000 Subject: [PATCH 080/210] feat(ui): store output image DTO in session context instead of just the name --- .../components/SimpleSession/context.tsx | 46 ++++++++++++------- .../components/SimpleSession/shared.ts | 15 +++--- 2 files changed, 39 insertions(+), 22 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 f89d76fe8d..0f79457f0d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -8,8 +8,9 @@ import type { Atom, WritableAtom } from 'nanostores'; import { atom, computed, effect } 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 { S } from 'services/api/types'; +import type { ImageDTO, S } from 'services/api/types'; import { $socket } from 'services/events/stores'; import { assert } from 'tsafe'; import { z } from 'zod'; @@ -21,14 +22,14 @@ export type ProgressData = { itemId: number; progressEvent: S['InvocationProgressEvent'] | null; progressImage: ProgressImage | null; - outputImageName: string | null; + imageDTO: ImageDTO | null; }; const getInitialProgressData = (itemId: number): ProgressData => ({ itemId, progressEvent: null, progressImage: null, - outputImageName: null, + imageDTO: null, }); export const useProgressData = ( @@ -74,7 +75,7 @@ const setProgress = ($progressData: WritableAtom>, itemId: data.item_id, progressEvent: data, progressImage: data.image ?? null, - outputImageName: null, + imageDTO: null, }, }); } @@ -89,7 +90,7 @@ type CanvasSessionContextValue = { $selectedItemId: WritableAtom; $selectedItem: Atom; $selectedItemIndex: Atom; - $selectedItemOutputImageName: Atom; + $selectedItemOutputImageDTO: Atom; $autoSwitch: WritableAtom; $lastLoadedItemId: WritableAtom; selectNext: () => void; @@ -187,7 +188,7 @@ export const CanvasSessionContextProvider = memo( * The currently selected queue item's output image name, or null if one is not selected or there is no output * image recorded. */ - const $selectedItemOutputImageName = useState(() => + const $selectedItemOutputImageDTO = useState(() => computed([$selectedItemId, $progressData], (selectedItemId, progressData) => { if (selectedItemId === null) { return null; @@ -196,7 +197,7 @@ export const CanvasSessionContextProvider = memo( if (!datum) { return null; } - return datum.outputImageName; + return datum.imageDTO; }) )[0]; @@ -328,7 +329,7 @@ export const CanvasSessionContextProvider = memo( }); // Clean up the progress data when a queue item is discarded. - const unsubCleanUpProgressData = $items.listen((items) => { + const unsubCleanUpProgressData = $items.listen(async (items) => { const progressData = $progressData.get(); const toDelete: number[] = []; @@ -343,7 +344,7 @@ export const CanvasSessionContextProvider = memo( ...datum, progressEvent: null, progressImage: null, - outputImageName: null, + imageDTO: null, }; } } @@ -352,21 +353,34 @@ export const CanvasSessionContextProvider = memo( const datum = progressData[item.item_id]; if (datum) { - if (datum.outputImageName) { + if (datum.imageDTO) { continue; } const outputImageName = getOutputImageName(item); if (!outputImageName) { continue; } + const imageDTO = await getImageDTOSafe(outputImageName); + if (!imageDTO) { + continue; + } toUpdate.push({ ...datum, - outputImageName, + imageDTO, }); } else { - const _datum = getInitialProgressData(item.item_id); - _datum.outputImageName = getOutputImageName(item); - toUpdate.push(_datum); + const outputImageName = getOutputImageName(item); + if (!outputImageName) { + continue; + } + const imageDTO = await getImageDTOSafe(outputImageName); + if (!imageDTO) { + continue; + } + toUpdate.push({ + ...getInitialProgressData(item.item_id), + imageDTO, + }); } } @@ -435,7 +449,7 @@ export const CanvasSessionContextProvider = memo( $selectedItem, $selectedItemIndex, $lastLoadedItemId, - $selectedItemOutputImageName, + $selectedItemOutputImageDTO, $itemCount, selectNext, selectPrev, @@ -452,7 +466,7 @@ export const CanvasSessionContextProvider = memo( $selectedItemId, $selectedItemIndex, session, - $selectedItemOutputImageName, + $selectedItemOutputImageDTO, $itemCount, selectNext, selectPrev, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts index d8b7ebc7b1..c0eee11713 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts @@ -1,9 +1,10 @@ -import { skipToken } from '@reduxjs/toolkit/query'; +import { useStore } from '@nanostores/react'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { isImageField } from 'features/nodes/types/common'; import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; import { round } from 'lodash-es'; -import { useMemo } from 'react'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { computed } from 'nanostores'; +import { useState } from 'react'; import type { S } from 'services/api/types'; import { objectEntries } from 'tsafe'; @@ -43,9 +44,11 @@ export const getOutputImageName = (item: S['SessionQueueItem']) => { }; export const useOutputImageDTO = (item: S['SessionQueueItem']) => { - const outputImageName = useMemo(() => getOutputImageName(item), [item]); - - const { currentData: imageDTO } = useGetImageDTOQuery(outputImageName ?? skipToken); + const ctx = useCanvasSessionContext(); + const $imageDTO = useState(() => + computed([ctx.$progressData], (progressData) => progressData[item.item_id]?.imageDTO ?? null) + )[0]; + const imageDTO = useStore($imageDTO); return imageDTO; }; From ba082ccc2f9bc334e5fced359209f56aa8fd78f1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:45:19 +1000 Subject: [PATCH 081/210] fix(ui): wait until last queue item deleted before flagging canvas session finished --- .../StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b6fc9d934a..5554ce9744 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarDiscardSelectedButton.tsx @@ -16,15 +16,15 @@ export const StagingAreaToolbarDiscardSelectedButton = memo(({ isDisabled }: { i const { t } = useTranslation(); - const discardSelected = useCallback(() => { + const discardSelected = useCallback(async () => { if (selectedItemId === null) { return; } + await deleteQueueItem.trigger(selectedItemId); const itemCount = ctx.$itemCount.get(); if (itemCount <= 1) { dispatch(canvasSessionGenerationFinished()); } - deleteQueueItem.trigger(selectedItemId); }, [selectedItemId, ctx.$itemCount, deleteQueueItem, dispatch]); return ( From 8c17bde4eaab8da1a1f256c7bc58b7fafa460c25 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:19:43 +1000 Subject: [PATCH 082/210] fix(ui): use imageDTO in staging area --- .../StagingAreaToolbarAcceptButton.tsx | 14 ++++---- ...tagingAreaToolbarMenuNewLayerFromImage.tsx | 34 +++++++++---------- ...AreaToolbarSaveSelectedToGalleryButton.tsx | 10 +++--- .../konva/CanvasStagingAreaModule.ts | 4 +-- 4 files changed, 31 insertions(+), 31 deletions(-) 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 1272887abc..8718385567 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarAcceptButton.tsx @@ -23,17 +23,17 @@ export const StagingAreaToolbarAcceptButton = memo(() => { const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const isCanvasFocused = useIsRegionFocused('canvas'); - const selectedItemImageName = useStore(ctx.$selectedItemOutputImageName); + const selectedItemImageDTO = useStore(ctx.$selectedItemOutputImageDTO); const deleteQueueItemsByDestination = useDeleteQueueItemsByDestination(); const { t } = useTranslation(); const acceptSelected = useCallback(() => { - if (!selectedItemImageName) { + if (!selectedItemImageDTO) { return; } const { x, y, width, height } = bboxRect; - const imageObject = imageNameToImageObject(selectedItemImageName, { width, height }); + const imageObject = imageNameToImageObject(selectedItemImageDTO.image_name, { width, height }); const overrides: Partial = { position: { x, y }, objects: [imageObject], @@ -43,7 +43,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => { dispatch(canvasSessionGenerationFinished()); deleteQueueItemsByDestination.trigger(ctx.session.id); }, [ - selectedItemImageName, + selectedItemImageDTO, bboxRect, dispatch, selectedEntityIdentifier?.type, @@ -56,9 +56,9 @@ export const StagingAreaToolbarAcceptButton = memo(() => { acceptSelected, { preventDefault: true, - enabled: isCanvasFocused && shouldShowStagedImage && selectedItemImageName !== null, + enabled: isCanvasFocused && shouldShowStagedImage && selectedItemImageDTO !== null, }, - [isCanvasFocused, shouldShowStagedImage, selectedItemImageName] + [isCanvasFocused, shouldShowStagedImage, selectedItemImageDTO] ); return ( @@ -68,7 +68,7 @@ export const StagingAreaToolbarAcceptButton = memo(() => { icon={} onClick={acceptSelected} colorScheme="invokeBlue" - isDisabled={!selectedItemImageName || !shouldShowStagedImage || deleteQueueItemsByDestination.isDisabled} + isDisabled={!selectedItemImageDTO || !shouldShowStagedImage || deleteQueueItemsByDestination.isDisabled} isLoading={deleteQueueItemsByDestination.isLoading} /> ); 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 54ab1578db..1c748139c5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuNewLayerFromImage.tsx @@ -16,7 +16,7 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { const canvasManager = useCanvasManager(); const { t } = useTranslation(); const ctx = useCanvasSessionContext(); - const imageName = useStore(ctx.$selectedItemOutputImageName); + const selectedItemOutputImageDTO = useStore(ctx.$selectedItemOutputImageDTO); const store = useAppStore(); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); @@ -29,11 +29,11 @@ export const StagingAreaToolbarNewLayerFromImageMenuItems = memo(() => { }, [t]); const onClickNewRasterLayerFromImage = useCallback(async () => { - if (!imageName) { + if (!selectedItemOutputImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(imageName, uploadImageArg); + const imageDTO = await copyImage(selectedItemOutputImageDTO.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(); - }, [imageName, store, toastSentToCanvas]); + }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); const onClickNewControlLayerFromImage = useCallback(async () => { - if (!imageName) { + if (!selectedItemOutputImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(imageName, uploadImageArg); + const imageDTO = await copyImage(selectedItemOutputImageDTO.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(); - }, [imageName, store, toastSentToCanvas]); + }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); const onClickNewInpaintMaskFromImage = useCallback(async () => { - if (!imageName) { + if (!selectedItemOutputImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(imageName, uploadImageArg); + const imageDTO = await copyImage(selectedItemOutputImageDTO.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(); - }, [imageName, store, toastSentToCanvas]); + }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); const onClickNewRegionalGuidanceFromImage = useCallback(async () => { - if (!imageName) { + if (!selectedItemOutputImageDTO) { return; } const { dispatch, getState } = store; - const imageDTO = await copyImage(imageName, uploadImageArg); + const imageDTO = await copyImage(selectedItemOutputImageDTO.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(); - }, [imageName, store, toastSentToCanvas]); + }, [selectedItemOutputImageDTO, store, toastSentToCanvas]); return ( } onClickCapture={onClickNewInpaintMaskFromImage} - isDisabled={!imageName || !shouldShowStagedImage} + isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} > {t('controlLayers.inpaintMask')} } onClickCapture={onClickNewRegionalGuidanceFromImage} - isDisabled={!imageName || !shouldShowStagedImage} + isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} > {t('controlLayers.regionalGuidance')} } onClickCapture={onClickNewControlLayerFromImage} - isDisabled={!imageName || !shouldShowStagedImage} + isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} > {t('controlLayers.controlLayer')} } onClickCapture={onClickNewRasterLayerFromImage} - isDisabled={!imageName || !shouldShowStagedImage} + isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} > {t('controlLayers.rasterLayer')} 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 7f7f6bea37..82476c1e4d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarSaveSelectedToGalleryButton.tsx @@ -17,13 +17,13 @@ export const StagingAreaToolbarSaveSelectedToGalleryButton = memo(() => { const canvasManager = useCanvasManager(); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const ctx = useCanvasSessionContext(); - const imageName = useStore(ctx.$selectedItemOutputImageName); + const selectedItemOutputImageDTO = useStore(ctx.$selectedItemOutputImageDTO); const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage); const { t } = useTranslation(); const saveSelectedImageToGallery = useCallback(async () => { - if (!imageName) { + if (!selectedItemOutputImageDTO) { 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(imageName, { + await copyImage(selectedItemOutputImageDTO.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, imageName, t]); + }, [autoAddBoardId, selectedItemOutputImageDTO, t]); return ( { icon={} onClick={saveSelectedImageToGallery} colorScheme="invokeBlue" - isDisabled={!imageName || !shouldShowStagedImage} + isDisabled={!selectedItemOutputImageDTO || !shouldShowStagedImage} /> ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index fa3ca84ec6..8262c70886 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts @@ -147,8 +147,8 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { const datum = progressData[selectedItemId]; - if (datum?.outputImageName) { - this.$imageSrc.set({ type: 'imageName', data: datum.outputImageName }); + if (datum?.imageDTO) { + this.$imageSrc.set({ type: 'imageName', data: datum.imageDTO.image_name }); return; } else if (datum?.progressImage) { this.$imageSrc.set({ type: 'dataURL', data: datum.progressImage.dataURL }); From c7ed351bab1dd74b9d3a781918bf9b72adf51fc8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:29:38 +1000 Subject: [PATCH 083/210] refactor(ui): async modal pattern; use for deleting images This was needed for a canvas flow change which is currently paused, but the new API is much much nicer to use, so I am keeping it. --- .../app/components/GlobalModalIsolator.tsx | 2 +- .../middleware/listenerMiddleware/index.ts | 7 +- .../listeners/boardAndImagesDeleted.ts | 2 +- .../ensureImageIsSelectedListener.ts | 16 + .../listeners/imageDeletionListeners.ts | 221 ------------- .../listeners/imageToDeleteSelected.ts | 32 -- invokeai/frontend/web/src/app/store/store.ts | 2 - .../components/DeleteImageModal.tsx | 97 ++---- .../deleteImageModal/store/actions.ts | 9 - .../deleteImageModal/store/initialState.ts | 6 - .../deleteImageModal/store/selectors.ts | 85 ----- .../features/deleteImageModal/store/slice.ts | 27 -- .../features/deleteImageModal/store/state.ts | 298 ++++++++++++++++++ .../components/Boards/DeleteBoardModal.tsx | 2 +- .../ImageContextMenu/ImageMenuItemDelete.tsx | 15 +- .../MultipleSelectionMenuItems.tsx | 7 +- .../GalleryImageDeleteIconButton.tsx | 10 +- .../gallery/hooks/useGalleryHotkeys.ts | 8 +- .../features/gallery/hooks/useImageActions.ts | 7 +- .../web/src/services/api/endpoints/images.ts | 21 +- 20 files changed, 376 insertions(+), 498 deletions(-) create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts delete mode 100644 invokeai/frontend/web/src/features/deleteImageModal/store/actions.ts delete mode 100644 invokeai/frontend/web/src/features/deleteImageModal/store/initialState.ts delete mode 100644 invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts delete mode 100644 invokeai/frontend/web/src/features/deleteImageModal/store/slice.ts create mode 100644 invokeai/frontend/web/src/features/deleteImageModal/store/state.ts diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index 782b338fe4..b7bacf3d2c 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -6,7 +6,7 @@ import { NewGallerySessionDialog, } from 'features/controlLayers/components/NewSessionConfirmationAlertDialog'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal'; +import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal'; import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 5db4acb243..aaf4c52c7a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -9,15 +9,14 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload'; import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; +import { addEnsureImageIsSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged'; import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema'; import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard'; -import { addImageDeletionListeners } from 'app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners'; import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard'; import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred'; import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred'; -import { addImageToDeleteSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected'; import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded'; import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected'; import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded'; @@ -46,9 +45,7 @@ export const addAppListener = addListener.withTypes(); addImageUploadedFulfilledListener(startAppListening); // Image deleted -addImageDeletionListeners(startAppListening); addDeleteBoardAndImagesFulfilledListener(startAppListening); -addImageToDeleteSelectedListener(startAppListening); // Image starred addImagesStarredListener(startAppListening); @@ -91,3 +88,5 @@ addAppConfigReceivedListener(startAppListening); addAdHocPostProcessingRequestedListener(startAppListening); addSetDefaultSettingsListener(startAppListening); + +addEnsureImageIsSelectedListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts index 4a0c79c72e..3ec4d7698e 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts @@ -1,6 +1,6 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import { getImageUsage } from 'features/deleteImageModal/store/selectors'; +import { getImageUsage } from 'features/deleteImageModal/store/state'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts new file mode 100644 index 0000000000..f07fe68c1b --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts @@ -0,0 +1,16 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; + +export const addEnsureImageIsSelectedListener = (startAppListening: AppStartListening) => { + // When we list images, if no images is selected, select the first one. + startAppListening({ + matcher: imagesApi.endpoints.listImages.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const selection = getState().gallery.selection; + if (selection.length === 0) { + dispatch(imageSelected(action.payload.items[0] ?? null)); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts deleted file mode 100644 index 0f286d6d1d..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { logger } from 'app/logging/logger'; -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import type { AppDispatch, RootState } from 'app/store/store'; -import { entityDeleted, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice'; -import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import { getEntityIdentifier } from 'features/controlLayers/store/types'; -import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; -import { isModalOpenChanged } from 'features/deleteImageModal/store/slice'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; -import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field'; -import { isInvocationNode } from 'features/nodes/types/invocation'; -import { forEach, intersectionBy } from 'lodash-es'; -import { imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; -import type { Param0 } from 'tsafe'; - -const log = logger('gallery'); - -//TODO(psyche): handle image deletion (canvas staging area?) - -// Some utils to delete images from different parts of the app -const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { - const actions: Param0[] = []; - state.nodes.present.nodes.forEach((node) => { - if (!isInvocationNode(node)) { - return; - } - - forEach(node.data.inputs, (input) => { - if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) { - actions.push( - fieldImageValueChanged({ - nodeId: node.data.id, - fieldName: input.name, - value: undefined, - }) - ); - return; - } - if (isImageFieldCollectionInputInstance(input)) { - actions.push( - fieldImageCollectionValueChanged({ - nodeId: node.data.id, - fieldName: input.name, - value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name), - }) - ); - } - }); - }); - - actions.forEach(dispatch); -}; - -const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { - selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => { - let shouldDelete = false; - for (const obj of objects) { - if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) { - shouldDelete = true; - break; - } - } - if (shouldDelete) { - dispatch(entityDeleted({ entityIdentifier: { id, type: 'control_layer' } })); - } - }); -}; - -const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { - selectCanvasSlice(state).referenceImages.entities.forEach((entity) => { - if (entity.ipAdapter.image?.image_name === imageDTO.image_name) { - dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: getEntityIdentifier(entity), imageDTO: null })); - } - }); -}; - -const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { - selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => { - let shouldDelete = false; - for (const obj of objects) { - if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) { - shouldDelete = true; - break; - } - } - if (shouldDelete) { - dispatch(entityDeleted({ entityIdentifier: { id, type: 'raster_layer' } })); - } - }); -}; - -export const addImageDeletionListeners = (startAppListening: AppStartListening) => { - // Handle single image deletion - startAppListening({ - actionCreator: imageDeletionConfirmed, - effect: async (action, { dispatch, getState }) => { - const { imageDTOs, imagesUsage } = action.payload; - - if (imageDTOs.length !== 1 || imagesUsage.length !== 1) { - // handle multiples in separate listener - return; - } - - const imageDTO = imageDTOs[0]; - const imageUsage = imagesUsage[0]; - - if (!imageDTO || !imageUsage) { - // satisfy noUncheckedIndexedAccess - return; - } - - try { - const state = getState(); - await dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO)).unwrap(); - - if (state.gallery.selection.some((i) => i.image_name === imageDTO.image_name)) { - // The deleted image was a selected image, we need to select the next image - const newSelection = state.gallery.selection.filter((i) => i.image_name !== imageDTO.image_name); - - if (newSelection.length > 0) { - return; - } - - // Get the current list of images and select the same index - const baseQueryArgs = selectListImagesQueryArgs(state); - const data = imagesApi.endpoints.listImages.select(baseQueryArgs)(state).data; - - if (data) { - const deletedImageIndex = data.items.findIndex((i) => i.image_name === imageDTO.image_name); - const nextImage = data.items[deletedImageIndex + 1] ?? data.items[0] ?? null; - if (nextImage?.image_name === imageDTO.image_name) { - // If the next image is the same as the deleted one, it means it was the last image, reset selection - dispatch(imageSelected(null)); - } else { - dispatch(imageSelected(nextImage)); - } - } - } - - deleteNodesImages(state, dispatch, imageDTO); - deleteReferenceImages(state, dispatch, imageDTO); - deleteRasterLayerImages(state, dispatch, imageDTO); - deleteControlLayerImages(state, dispatch, imageDTO); - } catch { - // no-op - } finally { - dispatch(isModalOpenChanged(false)); - } - }, - }); - - // Handle multiple image deletion - startAppListening({ - actionCreator: imageDeletionConfirmed, - effect: async (action, { dispatch, getState }) => { - const { imageDTOs, imagesUsage } = action.payload; - - if (imageDTOs.length <= 1 || imagesUsage.length <= 1) { - // handle singles in separate listener - return; - } - - try { - const state = getState(); - await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap(); - - if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) { - // Some selected images were deleted, need to select the next image - const queryArgs = selectListImagesQueryArgs(state); - const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state); - if (data) { - // When we delete multiple images, we clear the selection. Then, the the next time we load images, we will - // select the first one. This is handled below in the listener for `imagesApi.endpoints.listImages.matchFulfilled`. - dispatch(imageSelected(null)); - } - } - - // We need to reset the features where the image is in use - none of these work if their image(s) don't exist - - imageDTOs.forEach((imageDTO) => { - deleteNodesImages(state, dispatch, imageDTO); - deleteControlLayerImages(state, dispatch, imageDTO); - deleteReferenceImages(state, dispatch, imageDTO); - deleteRasterLayerImages(state, dispatch, imageDTO); - }); - } catch { - // no-op - } finally { - dispatch(isModalOpenChanged(false)); - } - }, - }); - - // When we list images, if no images is selected, select the first one. - startAppListening({ - matcher: imagesApi.endpoints.listImages.matchFulfilled, - effect: (action, { dispatch, getState }) => { - const selection = getState().gallery.selection; - if (selection.length === 0) { - dispatch(imageSelected(action.payload.items[0] ?? null)); - } - }, - }); - - startAppListening({ - matcher: imagesApi.endpoints.deleteImage.matchFulfilled, - effect: (action) => { - log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Image deleted'); - }, - }); - - startAppListening({ - matcher: imagesApi.endpoints.deleteImage.matchRejected, - effect: (action) => { - log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Unable to delete image'); - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts deleted file mode 100644 index c8f80ef451..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; -import { selectImageUsage } from 'features/deleteImageModal/store/selectors'; -import { imagesToDeleteSelected, isModalOpenChanged } from 'features/deleteImageModal/store/slice'; - -export const addImageToDeleteSelectedListener = (startAppListening: AppStartListening) => { - startAppListening({ - actionCreator: imagesToDeleteSelected, - effect: (action, { dispatch, getState }) => { - const imageDTOs = action.payload; - const state = getState(); - const { shouldConfirmOnDelete } = state.system; - const imagesUsage = selectImageUsage(getState()); - - const isImageInUse = - imagesUsage.some((i) => i.isRasterLayerImage) || - imagesUsage.some((i) => i.isControlLayerImage) || - imagesUsage.some((i) => i.isReferenceImage) || - imagesUsage.some((i) => i.isInpaintMaskImage) || - imagesUsage.some((i) => i.isUpscaleImage) || - imagesUsage.some((i) => i.isNodesImage) || - imagesUsage.some((i) => i.isRegionalGuidanceImage); - - if (shouldConfirmOnDelete || isImageInUse) { - dispatch(isModalOpenChanged(true)); - return; - } - - dispatch(imageDeletionConfirmed({ imageDTOs, imagesUsage })); - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 32f386fc44..8853d157ea 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -13,7 +13,6 @@ import { } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice'; import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice'; -import { deleteImageModalSlice } from 'features/deleteImageModal/store/slice'; import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice'; import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice'; @@ -54,7 +53,6 @@ const allReducers = { [configSlice.name]: configSlice.reducer, [uiSlice.name]: uiSlice.reducer, [dynamicPromptsSlice.name]: dynamicPromptsSlice.reducer, - [deleteImageModalSlice.name]: deleteImageModalSlice.reducer, [changeBoardModalSlice.name]: changeBoardModalSlice.reducer, [modelManagerV2Slice.name]: modelManagerV2Slice.reducer, [queueSlice.name]: queueSlice.reducer, diff --git a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx index 4fc9d6fa96..1e1213b1f2 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx +++ b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx @@ -1,98 +1,38 @@ import { ConfirmationAlertDialog, Divider, Flex, FormControl, FormLabel, Switch, Text } from '@invoke-ai/ui-library'; -import { createSelector } from '@reduxjs/toolkit'; -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; -import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; -import { getImageUsage, selectImageUsage } from 'features/deleteImageModal/store/selectors'; -import { - imageDeletionCanceled, - isModalOpenChanged, - selectDeleteImageModalSlice, -} from 'features/deleteImageModal/store/slice'; -import type { ImageUsage } from 'features/deleteImageModal/store/types'; -import { selectNodesSlice } from 'features/nodes/store/selectors'; -import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; -import { selectSystemSlice, setShouldConfirmOnDelete } from 'features/system/store/systemSlice'; -import { some } from 'lodash-es'; +import { useAppStore } from 'app/store/nanostores/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage'; +import { useDeleteImageModalApi, useDeleteImageModalState } from 'features/deleteImageModal/store/state'; +import { selectSystemShouldConfirmOnDelete, setShouldConfirmOnDelete } from 'features/system/store/systemSlice'; import type { ChangeEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import ImageUsageMessage from './ImageUsageMessage'; - -const selectImageUsages = createMemoizedSelector( - [selectDeleteImageModalSlice, selectNodesSlice, selectCanvasSlice, selectImageUsage, selectUpscaleSlice], - (deleteImageModal, nodes, canvas, imagesUsage, upscale) => { - const { imagesToDelete } = deleteImageModal; - - const allImageUsage = (imagesToDelete ?? []).map(({ image_name }) => - getImageUsage(nodes, canvas, upscale, image_name) - ); - - const imageUsageSummary: ImageUsage = { - isUpscaleImage: some(allImageUsage, (i) => i.isUpscaleImage), - isRasterLayerImage: some(allImageUsage, (i) => i.isRasterLayerImage), - isInpaintMaskImage: some(allImageUsage, (i) => i.isInpaintMaskImage), - isRegionalGuidanceImage: some(allImageUsage, (i) => i.isRegionalGuidanceImage), - isNodesImage: some(allImageUsage, (i) => i.isNodesImage), - isControlLayerImage: some(allImageUsage, (i) => i.isControlLayerImage), - isReferenceImage: some(allImageUsage, (i) => i.isReferenceImage), - }; - - return { - imagesToDelete, - imagesUsage, - imageUsageSummary, - }; - } -); - -const selectShouldConfirmOnDelete = createSelector(selectSystemSlice, (system) => system.shouldConfirmOnDelete); -const selectIsModalOpen = createSelector( - selectDeleteImageModalSlice, - (deleteImageModal) => deleteImageModal.isModalOpen -); - -const DeleteImageModal = () => { - useAssertSingleton('DeleteImageModal'); - const dispatch = useAppDispatch(); +export const DeleteImageModal = memo(() => { + const state = useDeleteImageModalState(); + const api = useDeleteImageModalApi(); + const { dispatch } = useAppStore(); const { t } = useTranslation(); - const shouldConfirmOnDelete = useAppSelector(selectShouldConfirmOnDelete); - const isModalOpen = useAppSelector(selectIsModalOpen); - const { imagesToDelete, imagesUsage, imageUsageSummary } = useAppSelector(selectImageUsages); + const shouldConfirmOnDelete = useAppSelector(selectSystemShouldConfirmOnDelete); const handleChangeShouldConfirmOnDelete = useCallback( (e: ChangeEvent) => dispatch(setShouldConfirmOnDelete(!e.target.checked)), [dispatch] ); - const handleClose = useCallback(() => { - dispatch(imageDeletionCanceled()); - dispatch(isModalOpenChanged(false)); - }, [dispatch]); - - const handleDelete = useCallback(() => { - if (!imagesToDelete.length || !imagesUsage.length) { - return; - } - dispatch(imageDeletionCanceled()); - dispatch(imageDeletionConfirmed({ imageDTOs: imagesToDelete, imagesUsage })); - }, [dispatch, imagesToDelete, imagesUsage]); - return ( - + {t('gallery.deleteImagePermanent')} {t('common.areYouSure')} @@ -103,6 +43,5 @@ const DeleteImageModal = () => { ); -}; - -export default memo(DeleteImageModal); +}); +DeleteImageModal.displayName = 'DeleteImageModal'; diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/actions.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/actions.ts deleted file mode 100644 index 7ec2a79bd1..0000000000 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/actions.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createAction } from '@reduxjs/toolkit'; -import type { ImageDTO } from 'services/api/types'; - -import type { ImageUsage } from './types'; - -export const imageDeletionConfirmed = createAction<{ - imageDTOs: ImageDTO[]; - imagesUsage: ImageUsage[]; -}>('deleteImageModal/imageDeletionConfirmed'); diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/initialState.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/initialState.ts deleted file mode 100644 index 112ad6858d..0000000000 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/initialState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { DeleteImageState } from './types'; - -export const initialDeleteImageState: DeleteImageState = { - imagesToDelete: [], - isModalOpen: false, -}; diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts deleted file mode 100644 index 9782ea5047..0000000000 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/selectors.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { CanvasState } from 'features/controlLayers/store/types'; -import { selectDeleteImageModalSlice } from 'features/deleteImageModal/store/slice'; -import { selectNodesSlice } from 'features/nodes/store/selectors'; -import type { NodesState } from 'features/nodes/store/types'; -import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field'; -import { isInvocationNode } from 'features/nodes/types/invocation'; -import type { UpscaleState } from 'features/parameters/store/upscaleSlice'; -import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; -import { some } from 'lodash-es'; - -import type { ImageUsage } from './types'; -// TODO(psyche): handle image deletion (canvas staging area?) -export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: UpscaleState, image_name: string) => { - const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) => - some(node.data.inputs, (input) => { - if (isImageFieldInputInstance(input)) { - if (input.value?.image_name === image_name) { - return true; - } - } - - if (isImageFieldCollectionInputInstance(input)) { - if (input.value?.some((value) => value?.image_name === image_name)) { - return true; - } - } - - return false; - }) - ); - - const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name; - - const isReferenceImage = canvas.referenceImages.entities.some( - ({ ipAdapter }) => ipAdapter.image?.image_name === image_name - ); - - const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) => - objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) - ); - - const isControlLayerImage = canvas.controlLayers.entities.some(({ objects }) => - objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) - ); - - const isInpaintMaskImage = canvas.inpaintMasks.entities.some(({ objects }) => - objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) - ); - - const isRegionalGuidanceImage = canvas.regionalGuidance.entities.some(({ referenceImages }) => - referenceImages.some(({ ipAdapter }) => ipAdapter.image?.image_name === image_name) - ); - - const imageUsage: ImageUsage = { - isUpscaleImage, - isRasterLayerImage, - isInpaintMaskImage, - isRegionalGuidanceImage, - isNodesImage, - isControlLayerImage, - isReferenceImage, - }; - - return imageUsage; -}; - -export const selectImageUsage = createMemoizedSelector( - selectDeleteImageModalSlice, - selectNodesSlice, - selectCanvasSlice, - selectUpscaleSlice, - (deleteImageModal, nodes, canvas, upscale) => { - const { imagesToDelete } = deleteImageModal; - - if (!imagesToDelete.length) { - return []; - } - - const imagesUsage = imagesToDelete.map((i) => getImageUsage(nodes, canvas, upscale, i.image_name)); - - return imagesUsage; - } -); diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/slice.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/slice.ts deleted file mode 100644 index 9efb7c395f..0000000000 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/slice.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; -import { createSlice } from '@reduxjs/toolkit'; -import type { RootState } from 'app/store/store'; -import type { ImageDTO } from 'services/api/types'; - -import { initialDeleteImageState } from './initialState'; - -export const deleteImageModalSlice = createSlice({ - name: 'deleteImageModal', - initialState: initialDeleteImageState, - reducers: { - isModalOpenChanged: (state, action: PayloadAction) => { - state.isModalOpen = action.payload; - }, - imagesToDeleteSelected: (state, action: PayloadAction) => { - state.imagesToDelete = action.payload; - }, - imageDeletionCanceled: (state) => { - state.imagesToDelete = []; - state.isModalOpen = false; - }, - }, -}); - -export const { isModalOpenChanged, imagesToDeleteSelected, imageDeletionCanceled } = deleteImageModalSlice.actions; - -export const selectDeleteImageModalSlice = (state: RootState) => state.deleteImageModal; diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts new file mode 100644 index 0000000000..1d88a77edc --- /dev/null +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts @@ -0,0 +1,298 @@ +import { useStore } from '@nanostores/react'; +import { getStore, useAppStore } from 'app/store/nanostores/store'; +import type { AppDispatch, AppGetState, RootState } from 'app/store/store'; +import { entityDeleted, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { type CanvasState, getEntityIdentifier } from 'features/controlLayers/store/types'; +import type { ImageUsage } from 'features/deleteImageModal/store/types'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import type { NodesState } from 'features/nodes/store/types'; +import { isImageFieldCollectionInputInstance, isImageFieldInputInstance } from 'features/nodes/types/field'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { selectUpscaleSlice, type UpscaleState } from 'features/parameters/store/upscaleSlice'; +import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemSlice'; +import { forEach, intersectionBy, some } from 'lodash-es'; +import { atom } from 'nanostores'; +import { useMemo } from 'react'; +import { imagesApi } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; +import type { Param0 } from 'tsafe'; + +// Implements an awaitable modal dialog for deleting images + +type DeleteImagesModalState = { + imageDTOs: ImageDTO[]; + usagePerImage: ImageUsage[]; + usageSummary: ImageUsage; + isOpen: boolean; + resolve?: () => void; + reject?: (reason?: string) => void; +}; + +const getInitialState = (): DeleteImagesModalState => ({ + imageDTOs: [], + usagePerImage: [], + usageSummary: { + isControlLayerImage: false, + isInpaintMaskImage: false, + isNodesImage: false, + isRasterLayerImage: false, + isRegionalGuidanceImage: false, + isReferenceImage: false, + isUpscaleImage: false, + }, + isOpen: false, +}); + +const $deleteModalState = atom(getInitialState()); + +const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise => { + const { getState, dispatch } = getStore(); + const imageUsage = getImageUsageFromImageDTOs(imageDTOs, getState()); + const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState()); + + if (!shouldConfirmOnDelete && !isAnyImageInUse(imageUsage)) { + // If we don't need to confirm and the images are not in use, delete them directly + await handleDeletions(imageDTOs, dispatch, getState); + } + + return new Promise((resolve, reject) => { + $deleteModalState.set({ + usagePerImage: imageUsage, + usageSummary: getImageUsageSummary(imageUsage), + imageDTOs, + isOpen: true, + resolve, + reject, + }); + }); +}; + +const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, getState: AppGetState) => { + try { + const state = getState(); + await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap(); + + if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) { + // Some selected images were deleted, need to select the next image + const queryArgs = selectListImagesQueryArgs(state); + const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state); + if (data) { + // When we delete multiple images, we clear the selection. Then, the the next time we load images, we will + // select the first one. This is handled below in the listener for `imagesApi.endpoints.listImages.matchFulfilled`. + dispatch(imageSelected(null)); + } + } + + // We need to reset the features where the image is in use - none of these work if their image(s) don't exist + for (const imageDTO of imageDTOs) { + deleteNodesImages(state, dispatch, imageDTO); + deleteControlLayerImages(state, dispatch, imageDTO); + deleteReferenceImages(state, dispatch, imageDTO); + deleteRasterLayerImages(state, dispatch, imageDTO); + } + } catch { + // no-op + } +}; + +const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => { + const state = $deleteModalState.get(); + await handleDeletions(state.imageDTOs, dispatch, getState); + state.resolve?.(); + closeSilently(); +}; + +const cancelDeletion = () => { + const state = $deleteModalState.get(); + state.reject?.('User canceled'); + closeSilently(); +}; + +const closeSilently = () => { + $deleteModalState.set(getInitialState()); +}; + +export const useDeleteImageModalState = () => { + const state = useStore($deleteModalState); + return state; +}; + +export const useDeleteImageModalApi = () => { + const { dispatch, getState } = useAppStore(); + const api = useMemo( + () => ({ + delete: deleteImagesWithDialog, + confirm: () => confirmDeletion(dispatch, getState), + cancel: cancelDeletion, + close: closeSilently, + getUsageSummary: getImageUsageSummary, + }), + [dispatch, getState] + ); + + return api; +}; + +const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): ImageUsage[] => { + if (imageDTOs.length === 0) { + return []; + } + + const nodes = selectNodesSlice(state); + const canvas = selectCanvasSlice(state); + const upscale = selectUpscaleSlice(state); + + return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, image_name)); +}; + +const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({ + isUpscaleImage: some(imageUsage, (i) => i.isUpscaleImage), + isRasterLayerImage: some(imageUsage, (i) => i.isRasterLayerImage), + isInpaintMaskImage: some(imageUsage, (i) => i.isInpaintMaskImage), + isRegionalGuidanceImage: some(imageUsage, (i) => i.isRegionalGuidanceImage), + isNodesImage: some(imageUsage, (i) => i.isNodesImage), + isControlLayerImage: some(imageUsage, (i) => i.isControlLayerImage), + isReferenceImage: some(imageUsage, (i) => i.isReferenceImage), +}); + +const isAnyImageInUse = (imageUsage: ImageUsage[]): boolean => + imageUsage.some( + (i) => + i.isRasterLayerImage || + i.isControlLayerImage || + i.isReferenceImage || + i.isInpaintMaskImage || + i.isUpscaleImage || + i.isNodesImage || + i.isRegionalGuidanceImage + ); + +// Some utils to delete images from different parts of the app +const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + const actions: Param0[] = []; + state.nodes.present.nodes.forEach((node) => { + if (!isInvocationNode(node)) { + return; + } + + forEach(node.data.inputs, (input) => { + if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) { + actions.push( + fieldImageValueChanged({ + nodeId: node.data.id, + fieldName: input.name, + value: undefined, + }) + ); + return; + } + if (isImageFieldCollectionInputInstance(input)) { + actions.push( + fieldImageCollectionValueChanged({ + nodeId: node.data.id, + fieldName: input.name, + value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name), + }) + ); + } + }); + }); + + actions.forEach(dispatch); +}; + +const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => { + let shouldDelete = false; + for (const obj of objects) { + if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) { + shouldDelete = true; + break; + } + } + if (shouldDelete) { + dispatch(entityDeleted({ entityIdentifier: { id, type: 'control_layer' } })); + } + }); +}; + +const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + selectCanvasSlice(state).referenceImages.entities.forEach((entity) => { + if (entity.ipAdapter.image?.image_name === imageDTO.image_name) { + dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: getEntityIdentifier(entity), imageDTO: null })); + } + }); +}; + +const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => { + let shouldDelete = false; + for (const obj of objects) { + if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) { + shouldDelete = true; + break; + } + } + if (shouldDelete) { + dispatch(entityDeleted({ entityIdentifier: { id, type: 'raster_layer' } })); + } + }); +}; + +export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: UpscaleState, image_name: string) => { + const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) => + some(node.data.inputs, (input) => { + if (isImageFieldInputInstance(input)) { + if (input.value?.image_name === image_name) { + return true; + } + } + + if (isImageFieldCollectionInputInstance(input)) { + if (input.value?.some((value) => value?.image_name === image_name)) { + return true; + } + } + + return false; + }) + ); + + const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name; + + const isReferenceImage = canvas.referenceImages.entities.some( + ({ ipAdapter }) => ipAdapter.image?.image_name === image_name + ); + + const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) => + objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) + ); + + const isControlLayerImage = canvas.controlLayers.entities.some(({ objects }) => + objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) + ); + + const isInpaintMaskImage = canvas.inpaintMasks.entities.some(({ objects }) => + objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) + ); + + const isRegionalGuidanceImage = canvas.regionalGuidance.entities.some(({ referenceImages }) => + referenceImages.some(({ ipAdapter }) => ipAdapter.image?.image_name === image_name) + ); + + const imageUsage: ImageUsage = { + isUpscaleImage, + isRasterLayerImage, + isInpaintMaskImage, + isRegionalGuidanceImage, + isNodesImage, + isControlLayerImage, + isReferenceImage, + }; + + return imageUsage; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx index 43ae24ed70..3247f7e0a0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx @@ -17,7 +17,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage'; -import { getImageUsage } from 'features/deleteImageModal/store/selectors'; +import { getImageUsage } from 'features/deleteImageModal/store/state'; import type { ImageUsage } from 'features/deleteImageModal/store/types'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx index 4f13848124..fcddd75483 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx @@ -1,6 +1,5 @@ -import { useAppDispatch } from 'app/store/storeHooks'; import { IconMenuItem } from 'common/components/IconMenuItem'; -import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,12 +7,16 @@ import { PiTrashSimpleBold } from 'react-icons/pi'; export const ImageMenuItemDelete = memo(() => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const deleteImageModal = useDeleteImageModalApi(); const imageDTO = useImageDTOContext(); - const onClick = useCallback(() => { - dispatch(imagesToDeleteSelected([imageDTO])); - }, [dispatch, imageDTO]); + const onClick = useCallback(async () => { + try { + await deleteImageModal.delete([imageDTO]); + } catch { + // noop; + } + }, [deleteImageModal, imageDTO]); return ( { const dispatch = useAppDispatch(); const selection = useAppSelector((s) => s.gallery.selection); const customStarUi = useStore($customStarUI); + const deleteImageModal = useDeleteImageModalApi(); const isBulkDownloadEnabled = useFeatureStatus('bulkDownload'); @@ -32,8 +33,8 @@ const MultipleSelectionMenuItems = () => { }, [dispatch, selection]); const handleDeleteSelection = useCallback(() => { - dispatch(imagesToDeleteSelected(selection)); - }, [dispatch, selection]); + deleteImageModal.delete(selection); + }, [deleteImageModal, selection]); const handleStarSelection = useCallback(() => { starImages({ imageDTOs: selection }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx index 93953381a9..574890f0ed 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx @@ -1,6 +1,5 @@ import { useShiftModifier } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; import type { MouseEvent } from 'react'; import { memo, useCallback } from 'react'; @@ -15,16 +14,17 @@ type Props = { export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => { const shift = useShiftModifier(); const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const deleteImageModal = useDeleteImageModalApi(); + const onClick = useCallback( (e: MouseEvent) => { e.stopPropagation(); if (!imageDTO) { return; } - dispatch(imagesToDeleteSelected([imageDTO])); + deleteImageModal.delete([imageDTO]); }, - [dispatch, imageDTO] + [deleteImageModal, imageDTO] ); if (!shift) { diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts index b69c6aa949..ab6da024fd 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts @@ -1,7 +1,7 @@ -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; -import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation'; import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; @@ -16,7 +16,6 @@ import { useListImagesQuery } from 'services/api/endpoints/images'; export const useGalleryHotkeys = () => { useAssertSingleton('useGalleryHotkeys'); const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination(); - const dispatch = useAppDispatch(); const selection = useAppSelector((s) => s.gallery.selection); const queryArgs = useAppSelector(selectListImagesQueryArgs); const queryResult = useListImagesQuery(queryArgs); @@ -25,6 +24,7 @@ export const useGalleryHotkeys = () => { const isWorkflowsFocused = useIsRegionFocused('workflows'); const isGalleryFocused = useIsRegionFocused('gallery'); const isImageViewerFocused = useIsRegionFocused('viewer'); + const deleteImageModal = useDeleteImageModalApi(); // When we are on the canvas tab, we need to disable the delete hotkey when the user is focused on the layers tab in // the right hand panel, because the same hotkey is used to delete layers. @@ -209,7 +209,7 @@ export const useGalleryHotkeys = () => { if (!selection.length) { return; } - dispatch(imagesToDeleteSelected(selection)); + deleteImageModal.delete(selection); }, options: { enabled: (isGalleryFocused || isImageViewerFocused) && isDeleteEnabledByTab && !isWorkflowsFocused, diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts index 1ba22baf4d..b63da55d89 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts @@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'; import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; import { handlers, parseAndRecallAllMetadata, @@ -34,6 +34,7 @@ export const useImageActions = (imageDTO: ImageDTO) => { const [hasSeed, setHasSeed] = useState(false); const [hasPrompts, setHasPrompts] = useState(false); const hasTemplates = useStore($hasTemplates); + const deleteImageModal = useDeleteImageModalApi(); useEffect(() => { const parseMetadata = async () => { @@ -169,8 +170,8 @@ export const useImageActions = (imageDTO: ImageDTO) => { }, [dispatch, imageDTO]); const _delete = useCallback(() => { - dispatch(imagesToDeleteSelected([imageDTO])); - }, [dispatch, imageDTO]); + deleteImageModal.delete([imageDTO]); + }, [deleteImageModal, imageDTO]); return { hasMetadata, diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 590b83fe57..30aad43449 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -2,6 +2,7 @@ import { $authToken } from 'app/store/nanostores/authToken'; import { getStore } from 'app/store/nanostores/store'; import type { BoardId } from 'features/gallery/store/types'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; +import { uniqBy } from 'lodash-es'; import type { components, paths } from 'services/api/schema'; import type { DeleteBoardResult, @@ -14,6 +15,7 @@ import type { UploadImageArg, } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; +import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -133,11 +135,12 @@ export const imagesApi = api.injectEndpoints({ }; }, invalidatesTags: (result, error, { imageDTOs }) => { - if (imageDTOs[0]) { - const categories = getCategories(imageDTOs[0]); - const boardId = imageDTOs[0].board_id ?? 'none'; + const tags: ApiTagDescription[] = []; + for (const imageDTO of imageDTOs) { + const categories = getCategories(imageDTO); + const boardId = imageDTO.board_id ?? 'none'; - const tags: ApiTagDescription[] = [ + tags.push( { type: 'ImageList', id: getListImagesUrl({ @@ -152,12 +155,12 @@ export const imagesApi = api.injectEndpoints({ { type: 'BoardImagesTotal', id: boardId, - }, - ]; - - return tags; + } + ); } - return []; + + const dedupedTags = uniqBy(tags, stableHash); + return dedupedTags; }, }), deleteUncategorizedImages: build.mutation({ From baa9141be3766540954242ece778ed5221d05a92 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:31:07 +1000 Subject: [PATCH 084/210] chore(ui): dpdm --- .../SimpleSession/QueueItemPreviewFull.tsx | 3 ++- .../SimpleSession/QueueItemPreviewMini.tsx | 4 ++-- .../components/SimpleSession/context.tsx | 10 ++++++++++ .../components/SimpleSession/shared.ts | 14 -------------- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx index 029ad29454..ba45a9f2ba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx @@ -1,11 +1,12 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; +import { useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/context'; import { ImageActions } from 'features/controlLayers/components/SimpleSession/ImageActions'; 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, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/shared'; +import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { DndImage } from 'features/dnd/DndImage'; import { memo, useCallback, useState } from 'react'; import type { S } from 'services/api/types'; 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 5851178b0f..cd69cd1bdb 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,11 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasSessionContext, useOutputImageDTO } 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'; import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; -import { getQueueItemElementId, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/shared'; +import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { DndImage } from 'features/dnd/DndImage'; import { memo, useCallback, useState } from 'react'; import type { S } from 'services/api/types'; 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 0f79457f0d..7529c6995d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -485,3 +485,13 @@ export const useCanvasSessionContext = () => { 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/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts index c0eee11713..0026b2a3fa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts @@ -1,10 +1,6 @@ -import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { isImageField } from 'features/nodes/types/common'; import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; import { round } from 'lodash-es'; -import { computed } from 'nanostores'; -import { useState } from 'react'; import type { S } from 'services/api/types'; import { objectEntries } from 'tsafe'; @@ -42,13 +38,3 @@ export const getOutputImageName = (item: S['SessionQueueItem']) => { return null; }; - -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; -}; From a5e5cbd7c3687b04ac2060c336b4435477ff7b89 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:47:46 +1000 Subject: [PATCH 085/210] feat(ui): simple session initial state cards are buttons --- .../components/SimpleSession/InitialState.tsx | 17 +++-------- .../InitialStateAddAStyleReference.tsx | 15 ++++++---- .../InitialStateButtonGridItem.tsx | 28 +++++++++++++++++++ .../InitialStateCardGridItem.tsx | 24 ---------------- .../InitialStateEditImageCard.tsx | 15 ++++++---- .../InitialStateGenerateFromText.tsx | 17 ++++------- .../InitialStateUseALayoutImageCard.tsx | 15 ++++++---- 7 files changed, 65 insertions(+), 66 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx index 25bace52b6..2e8cf7a29b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx @@ -3,7 +3,6 @@ import { Button, Divider, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { InitialStateAddAStyleReference } from 'features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference'; -import { InitialStateCardGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateCardGridItem'; import { InitialStateEditImageCard } from 'features/controlLayers/components/SimpleSession/InitialStateEditImageCard'; import { InitialStateGenerateFromText } from 'features/controlLayers/components/SimpleSession/InitialStateGenerateFromText'; import { InitialStateUseALayoutImageCard } from 'features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard'; @@ -29,18 +28,10 @@ export const InitialState = memo(() => { - - - - - - - - - - - - + + + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx index f41ad61ffa..0b97e5637e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx @@ -2,12 +2,13 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; -import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { newCanvasFromImage } from 'features/imageActions/actions'; import { memo, useCallback } from 'react'; -import { PiUserCircleGearBold } from 'react-icons/pi'; +import { PiUploadBold, PiUserCircleGearBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; const NEW_CANVAS_OPTIONS = { type: 'reference_image' } as const; @@ -23,17 +24,19 @@ export const InitialStateAddAStyleReference = memo(() => { }, [dispatch, getState] ); + const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); return ( - <> + Add a Style Reference Add an image to transfer its look. - - + + + - + ); }); InitialStateAddAStyleReference.displayName = 'InitialStateAddAStyleReference'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx new file mode 100644 index 0000000000..4861e4f475 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx @@ -0,0 +1,28 @@ +import type { GridItemProps } from '@invoke-ai/ui-library'; +import { Button, GridItem } from '@invoke-ai/ui-library'; +import { memo } from 'react'; + +export const InitialStateButtonGridItem = memo(({ children, ...rest }: GridItemProps) => { + return ( + + {children} + + ); +}); + +InitialStateButtonGridItem.displayName = 'InitialStateButtonGridItem'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx deleted file mode 100644 index 708b005db1..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateCardGridItem.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { GridItem } from '@invoke-ai/ui-library'; -import { memo, type PropsWithChildren } from 'react'; - -export const InitialStateCardGridItem = memo((props: PropsWithChildren) => { - return ( - - {props.children} - - ); -}); - -InitialStateCardGridItem.displayName = 'InitialStateCardGridItem'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx index d0c7d0032c..b6448f41d0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx @@ -2,12 +2,13 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; -import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { newCanvasFromImage } from 'features/imageActions/actions'; import { memo, useCallback } from 'react'; -import { PiPencilBold } from 'react-icons/pi'; +import { PiPencilBold, PiUploadBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; const NEW_CANVAS_OPTIONS = { type: 'raster_layer', withInpaintMask: true } as const; @@ -23,17 +24,19 @@ export const InitialStateEditImageCard = memo(() => { }, [dispatch, getState] ); + const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); return ( - <> + Edit Image Add an image to refine. - - + + + - + ); }); InitialStateEditImageCard.displayName = 'InitialStateEditImageCard'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx index 6d4fa5a2cf..67048c383f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx @@ -1,6 +1,7 @@ /* eslint-disable i18next/no-literal-string */ -import { Flex, Heading, Icon, IconButton, Text } from '@invoke-ai/ui-library'; +import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; +import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem'; import { memo } from 'react'; import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi'; @@ -14,20 +15,14 @@ const focusOnPrompt = () => { export const InitialStateGenerateFromText = memo(() => { return ( - <> + Generate from Text Enter a prompt and Invoke. - - } - variant="link" - h={8} - /> + + - + ); }); InitialStateGenerateFromText.displayName = 'InitialStateGenerateFromText'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx index 54d2ef6f75..955ace441f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx @@ -2,12 +2,13 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; -import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem'; import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { newCanvasFromImage } from 'features/imageActions/actions'; import { memo, useCallback } from 'react'; -import { PiRectangleDashedBold } from 'react-icons/pi'; +import { PiRectangleDashedBold, PiUploadBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; const NEW_CANVAS_OPTIONS = { type: 'control_layer', withResize: true } as const; @@ -23,17 +24,19 @@ export const InitialStateUseALayoutImageCard = memo(() => { }, [dispatch, getState] ); + const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); return ( - <> + Use a Layout Image Add an image to control composition. - - + + + - + ); }); InitialStateUseALayoutImageCard.displayName = 'InitialStateUseALayoutImageCard'; From aa93e95a9447dcd1c2ee506400c5ffe4f5205dbc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:18:06 +1000 Subject: [PATCH 086/210] feat(ui): split out ref images into own slice (WIP) --- .../listeners/boardAndImagesDeleted.ts | 4 +- .../listeners/modelsLoaded.ts | 15 +- invokeai/frontend/web/src/app/store/store.ts | 3 + .../common/hooks/useFilterableOutsideClick.ts | 159 +++++++++ .../components/CanvasAddEntityButtons.tsx | 18 - .../components/CanvasDropArea.tsx | 11 - .../CanvasEntityGroupList.tsx | 5 +- .../CanvasEntityList/CanvasEntityList.tsx | 2 - .../EntityListGlobalActionBarAddLayerMenu.tsx | 8 - ...tityListSelectedEntityActionBarOpacity.tsx | 14 +- .../components/IPAdapter/IPAdapter.tsx | 30 +- .../components/IPAdapter/IPAdapterList.tsx | 52 +-- .../IPAdapter/IPAdapterMenuItemPullBbox.tsx | 6 +- .../components/IPAdapter/IPAdapterPreview.tsx | 75 ++++ .../IPAdapter/IPAdapterSettings.tsx | 141 ++++---- .../IPAdapter/IPAdapterSettingsEmptyState.tsx | 14 +- .../RegionalGuidanceIPAdapterSettings.tsx | 6 +- .../RegionalReferenceImageModel.tsx | 0 .../{IPAdapter => common}/CLIPVisionModel.tsx | 0 .../common/CanvasEntityAddOfTypeButton.tsx | 9 +- .../CanvasEntityHeaderCommonActions.tsx | 5 +- .../common/CanvasEntityMenuItemsArrange.tsx | 5 - .../common/CanvasEntityMenuItemsMergeDown.tsx | 4 +- .../common/CanvasEntityMergeVisibleButton.tsx | 4 +- .../FLUXReduxImageInfluence.tsx | 0 .../contexts/EntityAdapterContext.tsx | 8 +- .../contexts/RefImageIdContext.ts | 10 + .../controlLayers/hooks/addLayerHooks.ts | 2 +- .../controlLayers/hooks/saveCanvasHooks.ts | 9 +- .../controlLayers/hooks/useEntityTitle.ts | 2 - .../controlLayers/hooks/useEntityTypeCount.ts | 2 - .../useEntityTypeInformationalPopover.ts | 3 - .../hooks/useEntityTypeIsHidden.ts | 1 - .../hooks/useEntityTypeString.ts | 4 - .../controlLayers/hooks/useEntityTypeTitle.ts | 2 - .../hooks/useIsEntityTypeEnabled.ts | 9 +- .../useNextRenderableEntityIdentifier.ts | 4 +- .../hooks/useVisibleEntityCountByType.ts | 3 - .../konva/CanvasCompositorModule.ts | 18 +- .../CanvasEntity/CanvasEntityAdapterBase.ts | 4 +- .../CanvasEntity/CanvasEntityFilterer.ts | 4 +- .../controlLayers/konva/CanvasEntity/types.ts | 4 +- .../controlLayers/konva/CanvasManager.ts | 14 +- .../konva/CanvasSegmentAnythingModule.ts | 4 +- .../konva/CanvasStateApiModule.ts | 5 +- .../konva/CanvasTool/CanvasToolModule.ts | 3 +- .../controlLayers/store/canvasSlice.ts | 277 +-------------- .../controlLayers/store/refImagesSlice.ts | 323 ++++++++++++++++++ .../features/controlLayers/store/selectors.ts | 41 +-- .../src/features/controlLayers/store/types.ts | 46 +-- .../src/features/controlLayers/store/util.ts | 27 +- .../features/deleteImageModal/store/state.ts | 28 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 16 +- .../components/Boards/DeleteBoardModal.tsx | 34 +- .../ImageMenuItemNewLayerFromImageSubMenu.tsx | 6 +- .../web/src/features/imageActions/actions.ts | 28 +- .../graph/generation/buildChatGPT4oGraph.ts | 4 +- .../util/graph/generation/buildFLUXGraph.ts | 6 +- .../util/graph/generation/buildSD1Graph.ts | 4 +- .../util/graph/generation/buildSDXLGraph.ts | 4 +- .../components/Core/ParamPositivePrompt.tsx | 2 + .../web/src/features/queue/store/readiness.ts | 19 +- 62 files changed, 871 insertions(+), 699 deletions(-) create mode 100644 invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => RegionalGuidance}/RegionalReferenceImageModel.tsx (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => common}/CLIPVisionModel.tsx (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => common}/FLUXReduxImageInfluence.tsx (100%) create mode 100644 invokeai/frontend/web/src/features/controlLayers/contexts/RefImageIdContext.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts index 3ec4d7698e..74a9145cc0 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts @@ -1,4 +1,5 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { getImageUsage } from 'features/deleteImageModal/store/state'; import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; @@ -20,9 +21,10 @@ export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppS const nodes = selectNodesSlice(state); const canvas = selectCanvasSlice(state); const upscale = selectUpscaleSlice(state); + const refImages = selectRefImagesSlice(state); deleted_images.forEach((image_name) => { - const imageUsage = getImageUsage(nodes, canvas, upscale, image_name); + const imageUsage = getImageUsage(nodes, canvas, upscale, refImages, image_name); if (imageUsage.isNodesImage && !wasNodeEditorReset) { dispatch(nodeEditorReset()); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts index fa142e85bc..8590c35810 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -1,11 +1,7 @@ import { logger } from 'app/logging/logger'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppDispatch, RootState } from 'app/store/store'; -import { - controlLayerModelChanged, - referenceImageIPAdapterModelChanged, - rgIPAdapterModelChanged, -} from 'features/controlLayers/store/canvasSlice'; +import { controlLayerModelChanged, rgIPAdapterModelChanged } from 'features/controlLayers/store/canvasSlice'; import { loraDeleted } from 'features/controlLayers/store/lorasSlice'; import { clipEmbedModelSelected, @@ -15,6 +11,7 @@ import { t5EncoderModelSelected, vaeSelected, } from 'features/controlLayers/store/paramsSlice'; +import { referenceImageIPAdapterModelChanged, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { modelSelected } from 'features/parameters/store/actions'; @@ -210,7 +207,7 @@ const handleControlAdapterModels: ModelHandler = (models, state, dispatch, log) const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { const ipaModels = models.filter(isIPAdapterModelConfig); - selectCanvasSlice(state).referenceImages.entities.forEach((entity) => { + selectRefImagesSlice(state).entities.forEach((entity) => { if (entity.ipAdapter.type !== 'ip_adapter') { return; } @@ -225,7 +222,7 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { return; } log.debug({ selectedIPAdapterModel }, 'Selected IP adapter model is not available, clearing'); - dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), modelConfig: null })); + dispatch(referenceImageIPAdapterModelChanged({ id: entity.id, modelConfig: null })); }); selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => { @@ -254,7 +251,7 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => { const fluxReduxModels = models.filter(isFluxReduxModelConfig); - selectCanvasSlice(state).referenceImages.entities.forEach((entity) => { + selectRefImagesSlice(state).entities.forEach((entity) => { if (entity.ipAdapter.type !== 'flux_redux') { return; } @@ -268,7 +265,7 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => { return; } log.debug({ selectedFLUXReduxModel }, 'Selected FLUX Redux model is not available, clearing'); - dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), modelConfig: null })); + dispatch(referenceImageIPAdapterModelChanged({ id: entity.id, modelConfig: null })); }); selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => { diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 8853d157ea..ec757494f5 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -13,6 +13,7 @@ import { } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice'; import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice'; +import { refImagesPersistConfig, refImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice'; import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice'; @@ -66,6 +67,7 @@ const allReducers = { [canvasSessionSlice.name]: canvasSessionSlice.reducer, [lorasSlice.name]: lorasSlice.reducer, [workflowLibrarySlice.name]: workflowLibrarySlice.reducer, + [refImagesSlice.name]: refImagesSlice.reducer, }; const rootReducer = combineReducers(allReducers); @@ -111,6 +113,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = { [canvasStagingAreaPersistConfig.name]: canvasStagingAreaPersistConfig, [lorasPersistConfig.name]: lorasPersistConfig, [workflowLibraryPersistConfig.name]: workflowLibraryPersistConfig, + [refImagesSlice.name]: refImagesPersistConfig, }; const unserialize: UnserializeFunction = (data, key) => { diff --git a/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts b/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts new file mode 100644 index 0000000000..6b45cb8554 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Adapted from https://github.com/chakra-ui/chakra-ui/blob/v2/packages/hooks/src/use-outside-click.ts + * + * The main change here is to support filtering of outside clicks via a `filter` function. + * + * This lets us work around issues with portals and components like popovers, which typically close on an outside click. + * + * For example, consider a popover that has a custom drop-down component inside it, which uses a portal to render + * the drop-down options. The original outside click handler would close the popover when clicking on the drop-down options, + * because the click is outside the popover - but we expect the popover to stay open in this case. + * + * A filter function like this can fix that: + * + * ```ts + * const filter = (el: HTMLElement) => el.className.includes('chakra-portal') || el.id.includes('react-select') + * ``` + * + * This ignores clicks on react-select-based drop-downs and Chakra UI portals and is used as the default filter. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +export function useCallbackRef any>( + callback: T | undefined, + deps: React.DependencyList = [] +) { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useCallback(((...args) => callbackRef.current?.(...args)) as T, deps); +} + +export interface UseOutsideClickProps { + /** + * Whether the hook is enabled + */ + enabled?: boolean; + /** + * The reference to a DOM element. + */ + ref: React.RefObject; + /** + * Function invoked when a click is triggered outside the referenced element. + */ + handler?: (e: Event) => void; + /** + * A function that filters the elements that should be considered as outside clicks. + * + * If omitted, a default filter function that ignores clicks in Chakra UI portals and react-select components is used. + */ + filter?: (el: HTMLElement) => boolean; +} + +const DEFAULT_FILTER = (el: HTMLElement) => el.className.includes('chakra-portal') || el.id.includes('react-select'); + +/** + * Example, used in components like Dialogs and Popovers, so they can close + * when a user clicks outside them. + */ +export function useFilterableOutsideClick(props: UseOutsideClickProps) { + const { ref, handler, enabled = true, filter = DEFAULT_FILTER } = props; + const savedHandler = useCallbackRef(handler); + + const stateRef = useRef({ + isPointerDown: false, + ignoreEmulatedMouseEvents: false, + }); + + const state = stateRef.current; + + useEffect(() => { + if (!enabled) { + return; + } + const onPointerDown: any = (e: PointerEvent) => { + if (isValidEvent(e, ref, filter)) { + state.isPointerDown = true; + } + }; + + const onMouseUp: any = (event: MouseEvent) => { + if (state.ignoreEmulatedMouseEvents) { + state.ignoreEmulatedMouseEvents = false; + return; + } + + if (state.isPointerDown && handler && isValidEvent(event, ref)) { + state.isPointerDown = false; + savedHandler(event); + } + }; + + const onTouchEnd = (event: TouchEvent) => { + state.ignoreEmulatedMouseEvents = true; + if (handler && state.isPointerDown && isValidEvent(event, ref)) { + state.isPointerDown = false; + savedHandler(event); + } + }; + + const doc = getOwnerDocument(ref.current); + doc.addEventListener('mousedown', onPointerDown, true); + doc.addEventListener('mouseup', onMouseUp, true); + doc.addEventListener('touchstart', onPointerDown, true); + doc.addEventListener('touchend', onTouchEnd, true); + + return () => { + doc.removeEventListener('mousedown', onPointerDown, true); + doc.removeEventListener('mouseup', onMouseUp, true); + doc.removeEventListener('touchstart', onPointerDown, true); + doc.removeEventListener('touchend', onTouchEnd, true); + }; + }, [handler, ref, savedHandler, state, enabled, filter]); +} + +function isValidEvent( + event: Event, + ref: React.RefObject, + filter?: (el: HTMLElement) => boolean +): boolean { + const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement; + + if (target) { + const doc = getOwnerDocument(target); + if (!doc.contains(target)) { + return false; + } + } + + if (ref.current?.contains(target)) { + return false; + } + + if (filter) { + // Check if the click is inside an element matching the filter. + // This is used for portal-awareness or other general exclusion cases. + let currentElement: HTMLElement | null = target; + // Traverse up the DOM tree from the target element. + while (currentElement && currentElement !== document.body) { + if (filter(currentElement)) { + return false; + } + currentElement = currentElement.parentElement; + } + } + + // If the click is not inside the ref and not inside a portal, it's a valid outside click. + return true; +} + +function getOwnerDocument(node?: Element | null): Document { + return node?.ownerDocument ?? document; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx index b39c590b9b..6027ed14d2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx @@ -2,7 +2,6 @@ import { Button, Flex, Heading } from '@invoke-ai/ui-library'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { useAddControlLayer, - useAddGlobalReferenceImage, useAddInpaintMask, useAddRasterLayer, useAddRegionalGuidance, @@ -19,9 +18,7 @@ export const CanvasAddEntityButtons = memo(() => { const addRegionalGuidance = useAddRegionalGuidance(); const addRasterLayer = useAddRasterLayer(); const addControlLayer = useAddControlLayer(); - const addGlobalReferenceImage = useAddGlobalReferenceImage(); const addRegionalReferenceImage = useAddRegionalReferenceImage(); - const isReferenceImageEnabled = useIsEntityTypeEnabled('reference_image'); const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance'); const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer'); const isInpaintLayerEnabled = useIsEntityTypeEnabled('inpaint_mask'); @@ -29,21 +26,6 @@ export const CanvasAddEntityButtons = memo(() => { return ( - - {t('controlLayers.global')} - - - - {t('controlLayers.regional')} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index adf21bd318..4bde508d2b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -12,9 +12,6 @@ const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget. const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ type: 'regional_guidance_with_reference_image', }); -const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ - type: 'reference_image', -}); export const CanvasDropArea = memo(() => { const { t } = useTranslation(); @@ -57,14 +54,6 @@ export const CanvasDropArea = memo(() => { isDisabled={isBusy} /> - - - ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx index 07ca2093a5..8f47075b9d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx @@ -14,7 +14,6 @@ import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/ import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle'; import { entitiesReordered } from 'features/controlLayers/store/canvasSlice'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import { isRenderableEntityType } from 'features/controlLayers/store/types'; import { singleCanvasEntityDndSource } from 'features/dnd/dnd'; import { triggerPostMoveFlash } from 'features/dnd/util'; import type { PropsWithChildren } from 'react'; @@ -165,8 +164,8 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI - {isRenderableEntityType(type) && } - {isRenderableEntityType(type) && } + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityList.tsx index e63641dab3..f8fdb7c66c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityList.tsx @@ -2,7 +2,6 @@ import { Flex } from '@invoke-ai/ui-library'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { ControlLayerEntityList } from 'features/controlLayers/components/ControlLayer/ControlLayerEntityList'; import { InpaintMaskList } from 'features/controlLayers/components/InpaintMask/InpaintMaskList'; -import { IPAdapterList } from 'features/controlLayers/components/IPAdapter/IPAdapterList'; import { RasterLayerEntityList } from 'features/controlLayers/components/RasterLayer/RasterLayerEntityList'; import { RegionalGuidanceEntityList } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList'; import { memo } from 'react'; @@ -11,7 +10,6 @@ export const CanvasEntityList = memo(() => { return ( - diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx index b1bea0e292..9ffdc58bf6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx @@ -1,7 +1,6 @@ import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAddControlLayer, - useAddGlobalReferenceImage, useAddInpaintMask, useAddRasterLayer, useAddRegionalGuidance, @@ -16,13 +15,11 @@ import { PiPlusBold } from 'react-icons/pi'; export const EntityListGlobalActionBarAddLayerMenu = memo(() => { const { t } = useTranslation(); const isBusy = useCanvasIsBusy(); - const addGlobalReferenceImage = useAddGlobalReferenceImage(); const addInpaintMask = useAddInpaintMask(); const addRegionalGuidance = useAddRegionalGuidance(); const addRegionalReferenceImage = useAddRegionalReferenceImage(); const addRasterLayer = useAddRasterLayer(); const addControlLayer = useAddControlLayer(); - const isReferenceImageEnabled = useIsEntityTypeEnabled('reference_image'); const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance'); const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer'); const isInpaintLayerEnabled = useIsEntityTypeEnabled('inpaint_mask'); @@ -41,11 +38,6 @@ export const EntityListGlobalActionBarAddLayerMenu = memo(() => { isDisabled={isBusy} /> - - } onClick={addGlobalReferenceImage} isDisabled={!isReferenceImageEnabled}> - {t('controlLayers.globalReferenceImage')} - - } onClick={addInpaintMask} isDisabled={!isInpaintLayerEnabled}> {t('controlLayers.inpaintMask')} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx index e59e09dfa1..0f47c5b7f6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx @@ -22,7 +22,6 @@ import { selectEntity, selectSelectedEntityIdentifier, } from 'features/controlLayers/store/selectors'; -import { isRenderableEntity } from 'features/controlLayers/store/types'; import { clamp, round } from 'lodash-es'; import type { KeyboardEvent } from 'react'; import { memo, useCallback, useEffect, useState } from 'react'; @@ -70,9 +69,6 @@ const selectOpacity = createSelector(selectCanvasSlice, (canvas) => { if (!selectedEntity) { return 1; // fallback to 100% opacity } - if (!isRenderableEntity(selectedEntity)) { - return 1; // fallback to 100% opacity - } // Opacity is a float from 0-1, but we want to display it as a percentage return selectedEntity.opacity; }); @@ -134,11 +130,7 @@ export const EntityListSelectedEntityActionBarOpacity = memo(() => { return ( - + {t('controlLayers.opacity')} { position="absolute" insetInlineEnd={0} h="full" - isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'reference_image'} + isDisabled={selectedEntityIdentifier === null} /> @@ -185,7 +177,7 @@ export const EntityListSelectedEntityActionBarOpacity = memo(() => { marks={marks} formatValue={formatSliderValue} alwaysShowMarks - isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'reference_image'} + isDisabled={selectedEntityIdentifier === null} /> diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx index 2c034b80f9..a31841a716 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx @@ -4,31 +4,25 @@ import { CanvasEntityHeader } from 'features/controlLayers/components/common/Can import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings'; -import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate'; -import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import { memo, useMemo } from 'react'; +import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { memo } from 'react'; type Props = { id: string; }; export const IPAdapter = memo(({ id }: Props) => { - const entityIdentifier = useMemo(() => ({ id, type: 'reference_image' }), [id]); - return ( - - - - - - - - - - - - + + + + + + + + + + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx index 38cdbde8c7..c803bae13c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx @@ -1,37 +1,37 @@ /* eslint-disable i18next/no-literal-string */ -import { createSelector } from '@reduxjs/toolkit'; -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; -import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter'; -import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; -import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { RefImagePreview } from 'features/controlLayers/components/IPAdapter/IPAdapterPreview'; +import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; import { memo } from 'react'; -const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { - return canvas.referenceImages.entities.map(getEntityIdentifier).toReversed(); -}); -const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { - return selectedEntityIdentifier?.type === 'reference_image'; -}); +const sx: SystemStyleObject = { + opacity: 0.3, + _hover: { + opacity: 1, + }, + transitionProperty: 'opacity', + transitionDuration: '0.2s', +}; -export const IPAdapterList = memo(() => { - const isSelected = useAppSelector(selectIsSelected); - const entityIdentifiers = useAppSelector(selectEntityIdentifiers); +export const RefImageList = memo((props: FlexProps) => { + const ids = useAppSelector(selectRefImageEntityIds); - if (entityIdentifiers.length === 0) { + if (ids.length === 0) { return null; } - if (entityIdentifiers.length > 0) { - return ( - - {entityIdentifiers.map((entityIdentifiers) => ( - - ))} - - ); - } + return ( + + {ids.map((id) => ( + + + + ))} + + ); }); -IPAdapterList.displayName = 'IPAdapterList'; +RefImageList.displayName = 'RefImageList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx index 504ac54f27..0dd01927c2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx @@ -1,5 +1,5 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { memo } from 'react'; @@ -8,8 +8,8 @@ import { PiBoundingBoxBold } from 'react-icons/pi'; export const IPAdapterMenuItemPullBbox = memo(() => { const { t } = useTranslation(); - const entityIdentifier = useEntityIdentifierContext('reference_image'); - const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); + const id = useRefImageIdContext(); + const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id); const isBusy = useCanvasIsBusy(); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx new file mode 100644 index 0000000000..58d6db2912 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx @@ -0,0 +1,75 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { + Flex, + Image, + Popover, + PopoverAnchor, + PopoverArrow, + PopoverBody, + PopoverContent, + Portal, +} from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useDisclosure } from 'common/hooks/useBoolean'; +import { useFilterableOutsideClick } from 'common/hooks/useFilterableOutsideClick'; +import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings'; +import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { selectRefImageEntityOrThrow, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; +import type { ImageWithDims } from 'features/controlLayers/store/types'; +import { memo, useMemo, useRef } from 'react'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; + +const sx: SystemStyleObject = { + opacity: 0.5, + _hover: { + opacity: 1, + }, + "&[data-is-open='true']": { + opacity: 1, + pointerEvents: 'none', + }, + transitionProperty: 'opacity', + transitionDuration: '0.2s', +}; + +export const RefImagePreview = memo(() => { + const id = useRefImageIdContext(); + const ref = useRef(null); + const disclosure = useDisclosure(false); + const selectEntity = useMemo( + () => + createSelector(selectRefImagesSlice, (refImages) => + selectRefImageEntityOrThrow(refImages, id, 'RefImagePreview') + ), + [id] + ); + const entity = useAppSelector(selectEntity); + useFilterableOutsideClick({ ref, handler: disclosure.close }); + + return ( + + + + + + + + + + + + + + + + ); +}); +RefImagePreview.displayName = 'RefImagePreview'; + +const Thumbnail = memo(({ image }: { image: ImageWithDims | null }) => { + const { data: imageDTO } = useGetImageDTOQuery(image?.image_name ?? skipToken); + return ; +}); +Thumbnail.displayName = 'Thumbnail'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index bc568dbcc7..fe2cd460b7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -1,17 +1,15 @@ -import { Flex, IconButton } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; -import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper'; +import { CLIPVisionModel } from 'features/controlLayers/components/common/CLIPVisionModel'; +import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence'; import { Weight } from 'features/controlLayers/components/common/Weight'; -import { CLIPVisionModel } from 'features/controlLayers/components/IPAdapter/CLIPVisionModel'; -import { FLUXReduxImageInfluence } from 'features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence'; import { GlobalReferenceImageModel } from 'features/controlLayers/components/IPAdapter/GlobalReferenceImageModel'; import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod'; import { IPAdapterSettingsEmptyState } from 'features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; -import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { referenceImageIPAdapterBeginEndStepPctChanged, referenceImageIPAdapterCLIPVisionModelChanged, @@ -20,11 +18,11 @@ import { referenceImageIPAdapterMethodChanged, referenceImageIPAdapterModelChanged, referenceImageIPAdapterWeightChanged, -} from 'features/controlLayers/store/canvasSlice'; -import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; -import { selectCanvasSlice, selectEntity, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; + selectRefImageEntity, + selectRefImageEntityOrThrow, + selectRefImagesSlice, +} from 'features/controlLayers/store/refImagesSlice'; import type { - CanvasEntityIdentifier, CLIPVisionModelV2, FLUXReduxImageInfluence as FLUXReduxImageInfluenceType, IPMethodV2, @@ -33,141 +31,138 @@ import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiBoundingBoxBold } from 'react-icons/pi'; import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types'; import { IPAdapterImagePreview } from './IPAdapterImagePreview'; -const buildSelectIPAdapter = (entityIdentifier: CanvasEntityIdentifier<'reference_image'>) => +const buildSelectIPAdapter = (id: string) => createSelector( - selectCanvasSlice, - (canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'IPAdapterSettings').ipAdapter + selectRefImagesSlice, + (refImages) => selectRefImageEntityOrThrow(refImages, id, 'IPAdapterSettings').ipAdapter ); const IPAdapterSettingsContent = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const entityIdentifier = useEntityIdentifierContext('reference_image'); - const selectIPAdapter = useMemo(() => buildSelectIPAdapter(entityIdentifier), [entityIdentifier]); + const id = useRefImageIdContext(); + const selectIPAdapter = useMemo(() => buildSelectIPAdapter(id), [id]); const ipAdapter = useAppSelector(selectIPAdapter); const onChangeBeginEndStepPct = useCallback( (beginEndStepPct: [number, number]) => { - dispatch(referenceImageIPAdapterBeginEndStepPctChanged({ entityIdentifier, beginEndStepPct })); + dispatch(referenceImageIPAdapterBeginEndStepPctChanged({ id, beginEndStepPct })); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const onChangeWeight = useCallback( (weight: number) => { - dispatch(referenceImageIPAdapterWeightChanged({ entityIdentifier, weight })); + dispatch(referenceImageIPAdapterWeightChanged({ id, weight })); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const onChangeIPMethod = useCallback( (method: IPMethodV2) => { - dispatch(referenceImageIPAdapterMethodChanged({ entityIdentifier, method })); + dispatch(referenceImageIPAdapterMethodChanged({ id, method })); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const onChangeFLUXReduxImageInfluence = useCallback( (imageInfluence: FLUXReduxImageInfluenceType) => { - dispatch(referenceImageIPAdapterFLUXReduxImageInfluenceChanged({ entityIdentifier, imageInfluence })); + dispatch(referenceImageIPAdapterFLUXReduxImageInfluenceChanged({ id, imageInfluence })); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const onChangeModel = useCallback( (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => { - dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier, modelConfig })); + dispatch(referenceImageIPAdapterModelChanged({ id, modelConfig })); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const onChangeCLIPVisionModel = useCallback( (clipVisionModel: CLIPVisionModelV2) => { - dispatch(referenceImageIPAdapterCLIPVisionModelChanged({ entityIdentifier, clipVisionModel })); + dispatch(referenceImageIPAdapterCLIPVisionModelChanged({ id, clipVisionModel })); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const onChangeImage = useCallback( (imageDTO: ImageDTO | null) => { - dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); + dispatch(referenceImageIPAdapterImageChanged({ id, imageDTO })); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const dndTargetData = useMemo( - () => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }, ipAdapter.image?.image_name), - [entityIdentifier, ipAdapter.image?.image_name] + () => setGlobalReferenceImageDndTarget.getData({ id }, ipAdapter.image?.image_name), + [id, ipAdapter.image?.image_name] ); - const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); - const isBusy = useCanvasIsBusy(); + // const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id); + // const isBusy = useCanvasIsBusy(); const isFLUX = useAppSelector(selectIsFLUX); return ( - - - - - {ipAdapter.type === 'ip_adapter' && ( - - )} - + + + {ipAdapter.type === 'ip_adapter' && ( + + )} + {/* } - /> - - - {ipAdapter.type === 'ip_adapter' && ( - - {!isFLUX && } - - - - )} - {ipAdapter.type === 'flux_redux' && ( - - - - )} - - */} + + + {ipAdapter.type === 'ip_adapter' && ( + + {!isFLUX && } + + + + )} + {ipAdapter.type === 'flux_redux' && ( + + + )} + + - + ); }); IPAdapterSettingsContent.displayName = 'IPAdapterSettingsContent'; -const buildSelectIPAdapterHasImage = (entityIdentifier: CanvasEntityIdentifier<'reference_image'>) => - createSelector(selectCanvasSlice, (canvas) => { - const referenceImage = selectEntity(canvas, entityIdentifier); +const buildSelectIPAdapterHasImage = (id: string) => + createSelector(selectRefImagesSlice, (refImages) => { + const referenceImage = selectRefImageEntity(refImages, id); return !!referenceImage && referenceImage.ipAdapter.image !== null; }); export const IPAdapterSettings = memo(() => { - const entityIdentifier = useEntityIdentifierContext('reference_image'); + const id = useRefImageIdContext(); - const selectIPAdapterHasImage = useMemo(() => buildSelectIPAdapterHasImage(entityIdentifier), [entityIdentifier]); + const selectIPAdapterHasImage = useMemo(() => buildSelectIPAdapterHasImage(id), [id]); const hasImage = useAppSelector(selectIPAdapterHasImage); if (!hasImage) { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx index 12b059e292..3bf6744d99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx @@ -1,7 +1,7 @@ import { Button, Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; @@ -15,24 +15,24 @@ import type { ImageDTO } from 'services/api/types'; export const IPAdapterSettingsEmptyState = memo(() => { const { t } = useTranslation(); - const entityIdentifier = useEntityIdentifierContext('reference_image'); + const id = useRefImageIdContext(); const dispatch = useAppDispatch(); const isBusy = useCanvasIsBusy(); const onUpload = useCallback( (imageDTO: ImageDTO) => { - setGlobalReferenceImage({ imageDTO, entityIdentifier, dispatch }); + setGlobalReferenceImage({ imageDTO, id, dispatch }); }, - [dispatch, entityIdentifier] + [dispatch, id] ); const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false }); const onClickGalleryButton = useCallback(() => { dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); - const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); + const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id); const dndTargetData = useMemo( - () => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }), - [entityIdentifier] + () => setGlobalReferenceImageDndTarget.getData({ id }), + [id] ); const components = useMemo( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 17e148a231..bd5c7c544c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -2,13 +2,13 @@ import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; +import { CLIPVisionModel } from 'features/controlLayers/components/common/CLIPVisionModel'; +import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence'; import { Weight } from 'features/controlLayers/components/common/Weight'; -import { CLIPVisionModel } from 'features/controlLayers/components/IPAdapter/CLIPVisionModel'; -import { FLUXReduxImageInfluence } from 'features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence'; import { IPAdapterImagePreview } from 'features/controlLayers/components/IPAdapter/IPAdapterImagePreview'; import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod'; -import { RegionalReferenceImageModel } from 'features/controlLayers/components/IPAdapter/RegionalReferenceImageModel'; import { RegionalGuidanceIPAdapterSettingsEmptyState } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState'; +import { RegionalReferenceImageModel } from 'features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { usePullBboxIntoRegionalGuidanceReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/RegionalReferenceImageModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/RegionalReferenceImageModel.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/CLIPVisionModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CLIPVisionModel.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/CLIPVisionModel.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/common/CLIPVisionModel.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx index 9fd9d614d1..957443c001 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityAddOfTypeButton.tsx @@ -2,7 +2,6 @@ import { IconButton } from '@invoke-ai/ui-library'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useAddControlLayer, - useAddGlobalReferenceImage, useAddInpaintMask, useAddRasterLayer, useAddRegionalGuidance, @@ -23,7 +22,6 @@ export const CanvasEntityAddOfTypeButton = memo(({ type }: Props) => { const addRegionalGuidance = useAddRegionalGuidance(); const addRasterLayer = useAddRasterLayer(); const addControlLayer = useAddControlLayer(); - const addGlobalReferenceImage = useAddGlobalReferenceImage(); const onClick = useCallback(() => { switch (type) { @@ -39,11 +37,8 @@ export const CanvasEntityAddOfTypeButton = memo(({ type }: Props) => { case 'control_layer': addControlLayer(); break; - case 'reference_image': - addGlobalReferenceImage(); - break; } - }, [addControlLayer, addGlobalReferenceImage, addInpaintMask, addRasterLayer, addRegionalGuidance, type]); + }, [addControlLayer, addInpaintMask, addRasterLayer, addRegionalGuidance, type]); const label = useMemo(() => { switch (type) { @@ -55,8 +50,6 @@ export const CanvasEntityAddOfTypeButton = memo(({ type }: Props) => { return t('controlLayers.addRasterLayer'); case 'control_layer': return t('controlLayers.addControlLayer'); - case 'reference_image': - return t('controlLayers.addGlobalReferenceImage'); } }, [type, t]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderCommonActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderCommonActions.tsx index 9c22778ff6..f630373f66 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderCommonActions.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeaderCommonActions.tsx @@ -4,17 +4,14 @@ import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/com import { CanvasEntityHeaderWarnings } from 'features/controlLayers/components/common/CanvasEntityHeaderWarnings'; import { CanvasEntityIsBookmarkedForQuickSwitchToggle } from 'features/controlLayers/components/common/CanvasEntityIsBookmarkedForQuickSwitchToggle'; import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { memo } from 'react'; export const CanvasEntityHeaderCommonActions = memo(() => { - const entityIdentifier = useEntityIdentifierContext(); - return ( - {entityIdentifier.type !== 'reference_image' && } + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsArrange.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsArrange.tsx index 897b8fdde5..f9f625c1df 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsArrange.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsArrange.tsx @@ -39,11 +39,6 @@ const getIndexAndCount = ( index: canvas.inpaintMasks.entities.findIndex((entity) => entity.id === id), count: canvas.inpaintMasks.entities.length, }; - } else if (type === 'reference_image') { - return { - index: canvas.referenceImages.entities.findIndex((entity) => entity.id === id), - count: canvas.referenceImages.entities.length, - }; } else { return { index: -1, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown.tsx index 076d639027..2e1eef13dc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown.tsx @@ -3,7 +3,7 @@ import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerP import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useEntityIdentifierBelowThisOne } from 'features/controlLayers/hooks/useNextRenderableEntityIdentifier'; -import type { CanvasRenderableEntityType } from 'features/controlLayers/store/types'; +import type { CanvasEntityType } from 'features/controlLayers/store/types'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiStackSimpleBold } from 'react-icons/pi'; @@ -12,7 +12,7 @@ export const CanvasEntityMenuItemsMergeDown = memo(() => { const { t } = useTranslation(); const canvasManager = useCanvasManager(); const isBusy = useCanvasIsBusy(); - const entityIdentifier = useEntityIdentifierContext(); + const entityIdentifier = useEntityIdentifierContext(); const entityIdentifierBelowThisOne = useEntityIdentifierBelowThisOne(entityIdentifier); const mergeDown = useCallback(() => { if (entityIdentifierBelowThisOne === null) { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx index da3d46c433..d5e1fdded7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityMergeVisibleButton.tsx @@ -2,13 +2,13 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useVisibleEntityCountByType } from 'features/controlLayers/hooks/useVisibleEntityCountByType'; -import type { CanvasRenderableEntityType } from 'features/controlLayers/store/types'; +import type { CanvasEntityType } from 'features/controlLayers/store/types'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiStackBold } from 'react-icons/pi'; type Props = { - type: CanvasRenderableEntityType; + type: CanvasEntityType; }; export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/FLUXReduxImageInfluence.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/FLUXReduxImageInfluence.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/common/FLUXReduxImageInfluence.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/contexts/EntityAdapterContext.tsx b/invokeai/frontend/web/src/features/controlLayers/contexts/EntityAdapterContext.tsx index 096a182a00..151dbc95a2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/contexts/EntityAdapterContext.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/contexts/EntityAdapterContext.tsx @@ -5,7 +5,7 @@ import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konv import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer'; import type { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance'; import type { CanvasEntityAdapterFromType } from 'features/controlLayers/konva/CanvasEntity/types'; -import type { CanvasEntityIdentifier, CanvasRenderableEntityType } from 'features/controlLayers/store/types'; +import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types'; import type { PropsWithChildren } from 'react'; import { createContext, memo, useContext, useMemo, useSyncExternalStore } from 'react'; import { assert } from 'tsafe'; @@ -96,15 +96,15 @@ export const RegionalGuidanceAdapterGate = memo(({ children }: PropsWithChildren return {children}; }); -export const useEntityAdapterContext = ( +export const useEntityAdapterContext = ( type?: T -): CanvasEntityAdapterFromType => { +): CanvasEntityAdapterFromType => { const adapter = useContext(EntityAdapterContext); assert(adapter, 'useEntityIdentifier must be used within a EntityIdentifierProvider'); if (type) { assert(adapter.entityIdentifier.type === type, 'useEntityIdentifier must be used with the correct type'); } - return adapter as CanvasEntityAdapterFromType; + return adapter as CanvasEntityAdapterFromType; }; RegionalGuidanceAdapterGate.displayName = 'RegionalGuidanceAdapterGate'; diff --git a/invokeai/frontend/web/src/features/controlLayers/contexts/RefImageIdContext.ts b/invokeai/frontend/web/src/features/controlLayers/contexts/RefImageIdContext.ts new file mode 100644 index 0000000000..cd4a51c7b2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/contexts/RefImageIdContext.ts @@ -0,0 +1,10 @@ +import { createContext, useContext } from 'react'; +import { assert } from 'tsafe'; + +export const RefImageIdContext = createContext(null); + +export const useRefImageIdContext = (): string => { + const id = useContext(RefImageIdContext); + assert(id, 'useRefImageIdContext must be used within a RefImageIdContext.Provider'); + return id; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts index a1b0ba67c5..5bdbfb5555 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -9,13 +9,13 @@ import { inpaintMaskDenoiseLimitAdded, inpaintMaskNoiseAdded, rasterLayerAdded, - referenceImageAdded, rgAdded, rgIPAdapterAdded, rgNegativePromptChanged, rgPositivePromptChanged, } from 'features/controlLayers/store/canvasSlice'; import { selectBase, selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; +import { referenceImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import type { CanvasEntityIdentifier, diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts index a1b2bac014..2e6437cd0b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts @@ -9,8 +9,6 @@ import { controlLayerAdded, entityRasterized, rasterLayerAdded, - referenceImageAdded, - referenceImageIPAdapterImageChanged, rgAdded, rgIPAdapterImageChanged, } from 'features/controlLayers/store/canvasSlice'; @@ -20,6 +18,7 @@ import { selectPositivePrompt, selectSeed, } from 'features/controlLayers/store/paramsSlice'; +import { referenceImageAdded, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasMetadata } from 'features/controlLayers/store/selectors'; import type { CanvasControlLayerState, @@ -306,13 +305,13 @@ export const usePullBboxIntoLayer = (entityIdentifier: CanvasEntityIdentifier<'c return func; }; -export const usePullBboxIntoGlobalReferenceImage = (entityIdentifier: CanvasEntityIdentifier<'reference_image'>) => { +export const usePullBboxIntoGlobalReferenceImage = (id: string) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO, _: Rect) => { - dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); + dispatch(referenceImageIPAdapterImageChanged({ id, imageDTO })); }; return { @@ -322,7 +321,7 @@ export const usePullBboxIntoGlobalReferenceImage = (entityIdentifier: CanvasEnti toastOk: t('controlLayers.pullBboxIntoReferenceImageOk'), toastError: t('controlLayers.pullBboxIntoReferenceImageError'), }; - }, [dispatch, entityIdentifier, t]); + }, [dispatch, id, t]); const func = useSaveCanvas(arg); return func; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts index f04f000c41..fde57b6a78 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTitle.ts @@ -32,8 +32,6 @@ export const useEntityTypeName = (type: CanvasEntityIdentifier['type']) => { return t('controlLayers.controlLayer'); case 'raster_layer': return t('controlLayers.rasterLayer'); - case 'reference_image': - return t('controlLayers.globalReferenceImage'); case 'regional_guidance': return t('controlLayers.regionalGuidance'); default: diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeCount.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeCount.ts index 693df61b61..cf70719c91 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeCount.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeCount.ts @@ -17,8 +17,6 @@ export const useEntityTypeCount = (type: CanvasEntityIdentifier['type']): number return canvas.inpaintMasks.entities.length; case 'regional_guidance': return canvas.regionalGuidance.entities.length; - case 'reference_image': - return canvas.referenceImages.entities.length; default: return 0; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeInformationalPopover.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeInformationalPopover.ts index 3ec856f03c..d3368339ba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeInformationalPopover.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeInformationalPopover.ts @@ -13,9 +13,6 @@ export const useEntityTypeInformationalPopover = (type: CanvasEntityIdentifier[' return 'rasterLayer'; case 'regional_guidance': return 'regionalGuidanceAndReferenceImage'; - case 'reference_image': - return 'globalReferenceImage'; - default: return undefined; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeIsHidden.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeIsHidden.ts index 7adbbc451b..04bc110fcc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeIsHidden.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeIsHidden.ts @@ -17,7 +17,6 @@ export const useEntityTypeIsHidden = (type: CanvasEntityIdentifier['type']): boo return canvas.inpaintMasks.isHidden; case 'regional_guidance': return canvas.regionalGuidance.isHidden; - case 'reference_image': default: return false; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeString.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeString.ts index 164f737baa..968b9210e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeString.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeString.ts @@ -15,10 +15,6 @@ export const useEntityTypeString = (type: CanvasEntityIdentifier['type'], plural return plural ? t('controlLayers.inpaintMask_withCount_other') : t('controlLayers.inpaintMask'); case 'regional_guidance': return plural ? t('controlLayers.regionalGuidance_withCount_other') : t('controlLayers.regionalGuidance'); - case 'reference_image': - return plural - ? t('controlLayers.globalReferenceImage_withCount_other') - : t('controlLayers.globalReferenceImage'); default: return ''; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeTitle.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeTitle.ts index def62664c3..f43cbf7961 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeTitle.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntityTypeTitle.ts @@ -21,8 +21,6 @@ export const useEntityTypeTitle = (type: CanvasEntityIdentifier['type']): string return t('controlLayers.inpaintMasks_withCount', { count, context }); case 'regional_guidance': return t('controlLayers.regionalGuidance_withCount', { count, context }); - case 'reference_image': - return t('controlLayers.globalReferenceImages_withCount', { count, context }); default: return ''; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useIsEntityTypeEnabled.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useIsEntityTypeEnabled.ts index 6d9619df66..6984677f3d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useIsEntityTypeEnabled.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useIsEntityTypeEnabled.ts @@ -7,7 +7,6 @@ import { selectIsImagen4, selectIsSD3, } from 'features/controlLayers/store/paramsSlice'; -import { selectActiveReferenceImageEntities } from 'features/controlLayers/store/selectors'; import type { CanvasEntityType } from 'features/controlLayers/store/types'; import { useMemo } from 'react'; import type { Equals } from 'tsafe'; @@ -20,15 +19,9 @@ export const useIsEntityTypeEnabled = (entityType: CanvasEntityType) => { const isImagen4 = useAppSelector(selectIsImagen4); const isChatGPT4o = useAppSelector(selectIsChatGTP4o); const isFluxKontext = useAppSelector(selectIsFluxKontext); - const activeReferenceImageEntities = useAppSelector(selectActiveReferenceImageEntities); const isEntityTypeEnabled = useMemo(() => { switch (entityType) { - case 'reference_image': - if (isFluxKontext) { - return activeReferenceImageEntities.length === 0; - } - return !isSD3 && !isCogView4 && !isImagen3 && !isImagen4; case 'regional_guidance': return !isSD3 && !isCogView4 && !isImagen3 && !isImagen4 && !isFluxKontext && !isChatGPT4o; case 'control_layer': @@ -40,7 +33,7 @@ export const useIsEntityTypeEnabled = (entityType: CanvasEntityType) => { default: assert>(false); } - }, [entityType, isSD3, isCogView4, isImagen3, isImagen4, isFluxKontext, isChatGPT4o, activeReferenceImageEntities]); + }, [entityType, isSD3, isCogView4, isImagen3, isImagen4, isFluxKontext, isChatGPT4o]); return isEntityTypeEnabled; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts index 48384eead5..f6328f20b7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts @@ -1,11 +1,11 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { selectCanvasSlice, selectEntityIdentifierBelowThisOne } from 'features/controlLayers/store/selectors'; -import type { CanvasRenderableEntityIdentifier } from 'features/controlLayers/store/types'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { useMemo } from 'react'; -export const useEntityIdentifierBelowThisOne = ( +export const useEntityIdentifierBelowThisOne = ( entityIdentifier: T ): T | null => { const selector = useMemo( diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useVisibleEntityCountByType.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useVisibleEntityCountByType.ts index 654d1fdf89..5749eb20e7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useVisibleEntityCountByType.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useVisibleEntityCountByType.ts @@ -4,7 +4,6 @@ import { selectActiveControlLayerEntities, selectActiveInpaintMaskEntities, selectActiveRasterLayerEntities, - selectActiveReferenceImageEntities, selectActiveRegionalGuidanceEntities, } from 'features/controlLayers/store/selectors'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; @@ -22,8 +21,6 @@ export const useVisibleEntityCountByType = (type: CanvasEntityIdentifier['type'] return createSelector(selectActiveInpaintMaskEntities, (entities) => entities.length); case 'regional_guidance': return createSelector(selectActiveRegionalGuidanceEntities, (entities) => entities.length); - case 'reference_image': - return createSelector(selectActiveReferenceImageEntities, (entities) => entities.length); default: assert(false, 'Invalid entity type'); } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 21099f76b6..dd85b709d2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -21,9 +21,9 @@ import { selectActiveRegionalGuidanceEntities, } from 'features/controlLayers/store/selectors'; import type { - CanvasRenderableEntityIdentifier, - CanvasRenderableEntityState, - CanvasRenderableEntityType, + CanvasEntityIdentifier, + CanvasEntityState, + CanvasEntityType, GenerationMode, Rect, } from 'features/controlLayers/store/types'; @@ -91,7 +91,7 @@ export class CanvasCompositorModule extends CanvasModuleBase { * @param type The optional entity type * @returns The rect */ - getVisibleRectOfType = (type?: CanvasRenderableEntityType): Rect => { + getVisibleRectOfType = (type?: CanvasEntityType): Rect => { const rects = []; for (const adapter of this.manager.getAllAdapters()) { @@ -139,8 +139,8 @@ export class CanvasCompositorModule extends CanvasModuleBase { * @param type The entity type * @returns The adapters for the given entity type that are eligible to be included in a composite */ - getVisibleAdaptersOfType = (type: T): CanvasEntityAdapterFromType[] => { - let entities: CanvasRenderableEntityState[]; + getVisibleAdaptersOfType = (type: T): CanvasEntityAdapterFromType[] => { + let entities: CanvasEntityState[]; switch (type) { case 'raster_layer': @@ -327,7 +327,7 @@ export class CanvasCompositorModule extends CanvasModuleBase { * @param deleteMergedEntities Whether to delete the merged entities after creating the new merged entity * @returns A promise that resolves to the image DTO, or null if the merge failed */ - mergeByEntityIdentifiers = async ( + mergeByEntityIdentifiers = async ( entityIdentifiers: T[], deleteMergedEntities: boolean ): Promise => { @@ -402,8 +402,8 @@ export class CanvasCompositorModule extends CanvasModuleBase { * @param type The type of entity to merge * @returns A promise that resolves to the image DTO, or null if the merge failed */ - mergeVisibleOfType = (type: CanvasRenderableEntityType): Promise => { - let entities: CanvasRenderableEntityState[]; + mergeVisibleOfType = (type: CanvasEntityType): Promise => { + let entities: CanvasEntityState[]; switch (type) { case 'raster_layer': diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts index 0d2e299cb1..c89f9e376a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts @@ -26,7 +26,7 @@ import { } from 'features/controlLayers/store/selectors'; import type { CanvasEntityIdentifier, - CanvasRenderableEntityState, + CanvasEntityState, LifecycleCallback, Rect, } from 'features/controlLayers/store/types'; @@ -42,7 +42,7 @@ import { assert } from 'tsafe'; import type { Jsonifiable, JsonObject } from 'type-fest'; export abstract class CanvasEntityAdapterBase< - T extends CanvasRenderableEntityState, + T extends CanvasEntityState, U extends string, > extends CanvasModuleBase { readonly type: U; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts index 517a4998a8..e33eda59c4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts @@ -9,7 +9,7 @@ import { addCoords, getKonvaNodeDebugAttrs, getPrefixedId } from 'features/contr import { selectAutoProcess } from 'features/controlLayers/store/canvasSettingsSlice'; import type { FilterConfig } from 'features/controlLayers/store/filters'; import { getFilterForModel, IMAGE_FILTERS } from 'features/controlLayers/store/filters'; -import type { CanvasImageState, CanvasRenderableEntityType } from 'features/controlLayers/store/types'; +import type { CanvasImageState, CanvasEntityType } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/util'; import { toast } from 'features/toast/toast'; import Konva from 'konva'; @@ -373,7 +373,7 @@ export class CanvasEntityFilterer extends CanvasModuleBase { * Saves the filtered image as a new entity of the given type. * @param type The type of entity to save the filtered image as. */ - saveAs = (type: CanvasRenderableEntityType) => { + saveAs = (type: CanvasEntityType) => { const imageState = this.$imageState.get(); if (!imageState) { this.log.warn('No image state to apply filter to'); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/types.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/types.ts index da8402f0f7..47dc71e128 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/types.ts @@ -2,7 +2,7 @@ import type { CanvasEntityAdapterControlLayer } from 'features/controlLayers/kon import type { CanvasEntityAdapterInpaintMask } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask'; import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer'; import type { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance'; -import type { CanvasRenderableEntityType } from 'features/controlLayers/store/types'; +import type { CanvasEntityType } from 'features/controlLayers/store/types'; export type CanvasEntityAdapter = | CanvasEntityAdapterRasterLayer @@ -10,7 +10,7 @@ export type CanvasEntityAdapter = | CanvasEntityAdapterInpaintMask | CanvasEntityAdapterRegionalGuidance; -export type CanvasEntityAdapterFromType = Extract< +export type CanvasEntityAdapterFromType = Extract< CanvasEntityAdapter, { state: { type: T } } >; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts index 52592c8019..b0be88c8a3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts @@ -16,11 +16,7 @@ import { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/Canvas import { CanvasWorkerModule } from 'features/controlLayers/konva/CanvasWorkerModule.js'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { $canvasManager } from 'features/controlLayers/store/ephemeral'; -import type { - CanvasEntityIdentifier, - CanvasRenderableEntityIdentifier, - CanvasRenderableEntityType, -} from 'features/controlLayers/store/types'; +import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types'; import { isControlLayerEntityIdentifier, isInpaintMaskEntityIdentifier, @@ -135,7 +131,7 @@ export class CanvasManager extends CanvasModuleBase { this.konva.previewLayer.add(this.tool.konva.group); } - getAdapter = ( + getAdapter = ( entityIdentifier: CanvasEntityIdentifier ): CanvasEntityAdapterFromType | null => { let adapter: CanvasEntityAdapter | undefined; @@ -163,7 +159,7 @@ export class CanvasManager extends CanvasModuleBase { return adapter as CanvasEntityAdapterFromType; }; - deleteAdapter = (entityIdentifier: CanvasRenderableEntityIdentifier): boolean => { + deleteAdapter = (entityIdentifier: CanvasEntityIdentifier): boolean => { switch (entityIdentifier.type) { case 'raster_layer': return this.adapters.rasterLayers.delete(entityIdentifier.id); @@ -178,7 +174,7 @@ export class CanvasManager extends CanvasModuleBase { } }; - getAdapters = (entityIdentifiers: CanvasRenderableEntityIdentifier[]): CanvasEntityAdapter[] => { + getAdapters = (entityIdentifiers: CanvasEntityIdentifier[]): CanvasEntityAdapter[] => { const adapters: CanvasEntityAdapter[] = []; for (const entityIdentifier of entityIdentifiers) { const adapter = this.getAdapter(entityIdentifier); @@ -199,7 +195,7 @@ export class CanvasManager extends CanvasModuleBase { ]; }; - createAdapter = (entityIdentifier: CanvasRenderableEntityIdentifier): CanvasEntityAdapter => { + createAdapter = (entityIdentifier: CanvasEntityIdentifier): CanvasEntityAdapter => { if (isRasterLayerEntityIdentifier(entityIdentifier)) { const adapter = new CanvasEntityAdapterRasterLayer(entityIdentifier, this); this.adapters.rasterLayers.set(adapter.id, adapter); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts index a1963bb971..d8a0e2154c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts @@ -16,7 +16,7 @@ import { import { selectAutoProcess } from 'features/controlLayers/store/canvasSettingsSlice'; import type { CanvasImageState, - CanvasRenderableEntityType, + CanvasEntityType, Coordinate, RgbaColor, SAMPointLabel, @@ -703,7 +703,7 @@ export class CanvasSegmentAnythingModule extends CanvasModuleBase { * Saves the segmented image as a new entity of the given type. * @param type The type of entity to save the segmented image as. */ - saveAs = (type: CanvasRenderableEntityType) => { + saveAs = (type: CanvasEntityType) => { const imageState = this.$imageState.get(); if (!imageState) { this.log.error('No image state to save as'); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index 60bfd2f309..26fc3d8c24 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -48,7 +48,7 @@ import type { Rect, RgbaColor, } from 'features/controlLayers/store/types'; -import { isRenderableEntityIdentifier, RGBA_BLACK } from 'features/controlLayers/store/types'; +import { RGBA_BLACK } from 'features/controlLayers/store/types'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import { atom, computed } from 'nanostores'; import type { Logger } from 'roarr'; @@ -576,9 +576,6 @@ export class CanvasStateApiModule extends CanvasModuleBase { if (!state.selectedEntityIdentifier) { return null; } - if (!isRenderableEntityIdentifier(state.selectedEntityIdentifier)) { - return null; - } return this.manager.getAdapter(state.selectedEntityIdentifier); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts index b110f97d0d..bc0f88e4c7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts @@ -22,7 +22,6 @@ import type { Coordinate, Tool, } from 'features/controlLayers/store/types'; -import { isRenderableEntityType } from 'features/controlLayers/store/types'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import { atom } from 'nanostores'; @@ -180,7 +179,7 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.bbox.syncCursorStyle(); } else if (tool === 'colorPicker') { this.tools.colorPicker.syncCursorStyle(); - } else if (selectedEntityAdapter && isRenderableEntityType(selectedEntityAdapter.entityIdentifier.type)) { + } else if (selectedEntityAdapter) { if (selectedEntityAdapter.$isDisabled.get()) { stage.setCursor('not-allowed'); } else if (selectedEntityAdapter.$isEntityTypeHidden.get()) { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 22eee9b0c2..4715679a65 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -36,10 +36,9 @@ import { ASPECT_RATIO_MAP } from 'features/parameters/components/Bbox/constants' import { API_BASE_MODELS } from 'features/parameters/types/constants'; import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension'; import type { IRect } from 'konva/lib/types'; -import { isEqual, merge } from 'lodash-es'; +import { merge } from 'lodash-es'; import type { UndoableOptions } from 'redux-undo'; import type { - ApiModelConfig, ControlLoRAModelConfig, ControlNetModelConfig, FLUXReduxModelConfig, @@ -47,7 +46,6 @@ import type { IPAdapterModelConfig, T2IAdapterModelConfig, } from 'services/api/types'; -import { assert } from 'tsafe'; import type { AspectRatioID, @@ -55,7 +53,6 @@ import type { CanvasControlLayerState, CanvasEntityIdentifier, CanvasRasterLayerState, - CanvasReferenceImageState, CanvasRegionalGuidanceState, CanvasState, CLIPVisionModelV2, @@ -77,20 +74,16 @@ import { isChatGPT4oAspectRatioID, isFluxKontextAspectRatioID, isImagenAspectRatioID, - isRenderableEntity, } from './types'; import { converters, getControlLayerState, getInpaintMaskState, getRasterLayerState, - getReferenceImageState, getRegionalGuidanceState, imageDTOToImageWithDims, - initialChatGPT4oReferenceImage, initialControlLoRA, initialControlNet, - initialFluxKontextReferenceImage, initialFLUXRedux, initialIPAdapter, initialT2IAdapter, @@ -560,204 +553,6 @@ export const canvasSlice = createSlice({ } layer.withTransparencyEffect = !layer.withTransparencyEffect; }, - //#region Global Reference Images - referenceImageAdded: { - reducer: ( - state, - action: PayloadAction<{ - id: string; - overrides?: Partial; - isSelected?: boolean; - isBookmarked?: boolean; - }> - ) => { - const { id, overrides, isSelected, isBookmarked } = action.payload; - const entityState = getReferenceImageState(id, overrides); - - state.referenceImages.entities.push(entityState); - const entityIdentifier = getEntityIdentifier(entityState); - - if (isSelected) { - state.selectedEntityIdentifier = entityIdentifier; - } - - if (isBookmarked) { - state.bookmarkedEntityIdentifier = entityIdentifier; - } - }, - prepare: (payload?: { - overrides?: Partial; - isSelected?: boolean; - isBookmarked?: boolean; - }) => ({ - payload: { ...payload, id: getPrefixedId('reference_image') }, - }), - }, - referenceImageRecalled: (state, action: PayloadAction<{ data: CanvasReferenceImageState }>) => { - const { data } = action.payload; - state.referenceImages.entities.push(data); - state.selectedEntityIdentifier = { type: 'reference_image', id: data.id }; - }, - referenceImageIPAdapterImageChanged: ( - state, - action: PayloadAction> - ) => { - const { entityIdentifier, imageDTO } = action.payload; - const entity = selectEntity(state, entityIdentifier); - if (!entity) { - return; - } - entity.ipAdapter.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; - }, - referenceImageIPAdapterMethodChanged: ( - state, - action: PayloadAction> - ) => { - const { entityIdentifier, method } = action.payload; - const entity = selectEntity(state, entityIdentifier); - if (!entity) { - return; - } - if (entity.ipAdapter.type !== 'ip_adapter') { - return; - } - entity.ipAdapter.method = method; - }, - referenceImageIPAdapterFLUXReduxImageInfluenceChanged: ( - state, - action: PayloadAction> - ) => { - const { entityIdentifier, imageInfluence } = action.payload; - const entity = selectEntity(state, entityIdentifier); - if (!entity) { - return; - } - if (entity.ipAdapter.type !== 'flux_redux') { - return; - } - entity.ipAdapter.imageInfluence = imageInfluence; - }, - referenceImageIPAdapterModelChanged: ( - state, - action: PayloadAction< - EntityIdentifierPayload< - { modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig | null }, - 'reference_image' - > - > - ) => { - const { entityIdentifier, modelConfig } = action.payload; - const entity = selectEntity(state, entityIdentifier); - if (!entity) { - return; - } - - const oldModel = entity.ipAdapter.model; - - // First set the new model - entity.ipAdapter.model = modelConfig ? zModelIdentifierField.parse(modelConfig) : null; - - if (!entity.ipAdapter.model) { - return; - } - - if (isEqual(oldModel, entity.ipAdapter.model)) { - // Nothing changed, so we don't need to do anything - return; - } - - // The type of ref image depends on the model. When the user switches the model, we rebuild the ref image. - // When we switch the model, we keep the image the same, but change the other parameters. - - if (entity.ipAdapter.model.base === 'chatgpt-4o') { - // Switching to chatgpt-4o ref image - entity.ipAdapter = { - ...initialChatGPT4oReferenceImage, - image: entity.ipAdapter.image, - model: entity.ipAdapter.model, - }; - return; - } - - if (entity.ipAdapter.model.base === 'flux-kontext') { - // Switching to flux-kontext - entity.ipAdapter = { - ...initialFluxKontextReferenceImage, - image: entity.ipAdapter.image, - model: entity.ipAdapter.model, - }; - return; - } - - if (entity.ipAdapter.model.type === 'flux_redux') { - // Switching to flux_redux - entity.ipAdapter = { - ...initialFLUXRedux, - image: entity.ipAdapter.image, - model: entity.ipAdapter.model, - }; - return; - } - - if (entity.ipAdapter.model.type === 'ip_adapter') { - // Switching to ip_adapter - entity.ipAdapter = { - ...initialIPAdapter, - image: entity.ipAdapter.image, - model: entity.ipAdapter.model, - }; - // Ensure that the IP Adapter model is compatible with the CLIP Vision model - if (entity.ipAdapter.model?.base === 'flux') { - entity.ipAdapter.clipVisionModel = 'ViT-L'; - } else if (entity.ipAdapter.clipVisionModel === 'ViT-L') { - // Fall back to ViT-H (ViT-G would also work) - entity.ipAdapter.clipVisionModel = 'ViT-H'; - } - return; - } - }, - referenceImageIPAdapterCLIPVisionModelChanged: ( - state, - action: PayloadAction> - ) => { - const { entityIdentifier, clipVisionModel } = action.payload; - const entity = selectEntity(state, entityIdentifier); - if (!entity) { - return; - } - if (entity.ipAdapter.type !== 'ip_adapter') { - return; - } - entity.ipAdapter.clipVisionModel = clipVisionModel; - }, - referenceImageIPAdapterWeightChanged: ( - state, - action: PayloadAction> - ) => { - const { entityIdentifier, weight } = action.payload; - const entity = selectEntity(state, entityIdentifier); - if (!entity) { - return; - } - if (entity.ipAdapter.type !== 'ip_adapter') { - return; - } - entity.ipAdapter.weight = weight; - }, - referenceImageIPAdapterBeginEndStepPctChanged: ( - state, - action: PayloadAction> - ) => { - const { entityIdentifier, beginEndStepPct } = action.payload; - const entity = selectEntity(state, entityIdentifier); - if (!entity) { - return; - } - if (entity.ipAdapter.type !== 'ip_adapter') { - return; - } - entity.ipAdapter.beginEndStepPct = beginEndStepPct; - }, //#region Regional Guidance rgAdded: { reducer: ( @@ -1466,13 +1261,10 @@ export const canvasSlice = createSlice({ const entity = selectEntity(state, entityIdentifier); if (!entity) { return; - } else if (isRenderableEntity(entity)) { - entity.isEnabled = true; - entity.objects = []; - entity.position = { x: 0, y: 0 }; - } else { - assert(false, 'Not implemented'); } + entity.isEnabled = true; + entity.objects = []; + entity.position = { x: 0, y: 0 }; }, entityDuplicated: (state, action: PayloadAction) => { const { entityIdentifier } = action.payload; @@ -1501,10 +1293,6 @@ export const canvasSlice = createSlice({ } state.regionalGuidance.entities.push(newEntity); break; - case 'reference_image': - newEntity.id = getPrefixedId('reference_image'); - state.referenceImages.entities.push(newEntity); - break; case 'inpaint_mask': newEntity.id = getPrefixedId('inpaint_mask'); state.inpaintMasks.entities.push(newEntity); @@ -1558,9 +1346,7 @@ export const canvasSlice = createSlice({ return; } - if (isRenderableEntity(entity)) { - entity.position = position; - } + entity.position = position; }, entityMovedBy: (state, action: PayloadAction) => { const { entityIdentifier, offset } = action.payload; @@ -1569,10 +1355,6 @@ export const canvasSlice = createSlice({ return; } - if (!isRenderableEntity(entity)) { - return; - } - entity.position.x += offset.x; entity.position.y += offset.y; }, @@ -1583,11 +1365,9 @@ export const canvasSlice = createSlice({ return; } - if (isRenderableEntity(entity)) { - if (replaceObjects) { - entity.objects = [imageObject]; - entity.position = position; - } + if (replaceObjects) { + entity.objects = [imageObject]; + entity.position = position; } if (isSelected) { @@ -1601,10 +1381,6 @@ export const canvasSlice = createSlice({ return; } - if (!isRenderableEntity(entity)) { - assert(false, `Cannot add a brush line to a non-drawable entity of type ${entity.type}`); - } - // TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not // re-render it (reference equality check). I don't like this behaviour. entity.objects.push({ @@ -1620,10 +1396,6 @@ export const canvasSlice = createSlice({ return; } - if (!isRenderableEntity(entity)) { - assert(false, `Cannot add a eraser line to a non-drawable entity of type ${entity.type}`); - } - // TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not // re-render it (reference equality check). I don't like this behaviour. entity.objects.push({ @@ -1639,10 +1411,6 @@ export const canvasSlice = createSlice({ return; } - if (!isRenderableEntity(entity)) { - assert(false, `Cannot add a rect to a non-drawable entity of type ${entity.type}`); - } - // TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not // re-render it (reference equality check). I don't like this behaviour. entity.objects.push({ ...rect }); @@ -1673,9 +1441,6 @@ export const canvasSlice = createSlice({ (rg) => rg.id !== entityIdentifier.id ); break; - case 'reference_image': - state.referenceImages.entities = state.referenceImages.entities.filter((rg) => rg.id !== entityIdentifier.id); - break; case 'inpaint_mask': state.inpaintMasks.entities = state.inpaintMasks.entities.filter((rg) => rg.id !== entityIdentifier.id); break; @@ -1747,12 +1512,6 @@ export const canvasSlice = createSlice({ entityIdentifiers as CanvasEntityIdentifier<'regional_guidance'>[] ); break; - case 'reference_image': - state.referenceImages.entities = reorderEntities( - state.referenceImages.entities, - entityIdentifiers as CanvasEntityIdentifier<'reference_image'>[] - ); - break; } }, entityOpacityChanged: (state, action: PayloadAction>) => { @@ -1761,9 +1520,6 @@ export const canvasSlice = createSlice({ if (!entity) { return; } - if (entity.type === 'reference_image') { - return; - } entity.opacity = opacity; }, allEntitiesOfTypeIsHiddenToggled: (state, action: PayloadAction<{ type: CanvasEntityIdentifier['type'] }>) => { @@ -1782,9 +1538,6 @@ export const canvasSlice = createSlice({ case 'regional_guidance': state.regionalGuidance.isHidden = !state.regionalGuidance.isHidden; break; - case 'reference_image': - // no-op - break; } }, allEntitiesDeleted: (state) => { @@ -1794,14 +1547,12 @@ export const canvasSlice = createSlice({ state.controlLayers = initialState.controlLayers; state.inpaintMasks = initialState.inpaintMasks; state.regionalGuidance = initialState.regionalGuidance; - state.referenceImages = initialState.referenceImages; }, canvasMetadataRecalled: (state, action: PayloadAction) => { - const { controlLayers, inpaintMasks, rasterLayers, referenceImages, regionalGuidance } = action.payload; + const { controlLayers, inpaintMasks, rasterLayers, regionalGuidance } = action.payload; state.controlLayers.entities = controlLayers; state.inpaintMasks.entities = inpaintMasks; state.rasterLayers.entities = rasterLayers; - state.referenceImages.entities = referenceImages; state.regionalGuidance.entities = regionalGuidance; return state; }, @@ -1928,16 +1679,6 @@ export const { controlLayerWeightChanged, controlLayerBeginEndStepPctChanged, controlLayerWithTransparencyEffectToggled, - // IP Adapters - referenceImageAdded, - // referenceImageRecalled, - referenceImageIPAdapterImageChanged, - referenceImageIPAdapterMethodChanged, - referenceImageIPAdapterModelChanged, - referenceImageIPAdapterCLIPVisionModelChanged, - referenceImageIPAdapterWeightChanged, - referenceImageIPAdapterBeginEndStepPctChanged, - referenceImageIPAdapterFLUXReduxImageInfluenceChanged, // Regions rgAdded, // rgRecalled, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts new file mode 100644 index 0000000000..8188342c11 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts @@ -0,0 +1,323 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSelector, createSlice } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import type { PersistConfig, RootState } from 'app/store/store'; +import { deepClone } from 'common/util/deepClone'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { canvasMetadataRecalled } from 'features/controlLayers/store/canvasSlice'; +import type { FLUXReduxImageInfluence, RefImagesState } from 'features/controlLayers/store/types'; +import { zModelIdentifierField } from 'features/nodes/types/common'; +import { isEqual } from 'lodash-es'; +import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types'; +import { assert } from 'tsafe'; +import type { PartialDeep } from 'type-fest'; + +import type { CanvasReferenceImageState, CLIPVisionModelV2, IPMethodV2 } from './types'; +import { getInitialRefImagesState } from './types'; +import { + getReferenceImageState, + imageDTOToImageWithDims, + initialChatGPT4oReferenceImage, + initialFLUXRedux, + initialIPAdapter, +} from './util'; + +type PayloadWithId = T extends void + ? { id: string } + : { + id: string; + } & T; + +export const refImagesSlice = createSlice({ + name: 'refImages', + initialState: getInitialRefImagesState(), + reducers: { + referenceImageAdded: { + reducer: ( + state, + action: PayloadAction<{ + id: string; + overrides?: PartialDeep; + isSelected?: boolean; + }> + ) => { + const { id, overrides, isSelected } = action.payload; + const entityState = getReferenceImageState(id, overrides); + + state.entities.push(entityState); + + if (isSelected) { + state.selectedId = entityState.id; + } + }, + prepare: (payload?: { overrides?: PartialDeep; isSelected?: boolean }) => ({ + payload: { ...payload, id: getPrefixedId('reference_image') }, + }), + }, + referenceImageRecalled: (state, action: PayloadAction<{ data: CanvasReferenceImageState }>) => { + const { data } = action.payload; + state.entities.push(data); + state.selectedId = data.id; + }, + referenceImageIPAdapterImageChanged: ( + state, + action: PayloadAction> + ) => { + const { id, imageDTO } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + entity.ipAdapter.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; + }, + referenceImageIPAdapterMethodChanged: (state, action: PayloadAction>) => { + const { id, method } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + if (entity.ipAdapter.type !== 'ip_adapter') { + return; + } + entity.ipAdapter.method = method; + }, + referenceImageIPAdapterFLUXReduxImageInfluenceChanged: ( + state, + action: PayloadAction> + ) => { + const { id, imageInfluence } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + if (entity.ipAdapter.type !== 'flux_redux') { + return; + } + entity.ipAdapter.imageInfluence = imageInfluence; + }, + referenceImageIPAdapterModelChanged: ( + state, + action: PayloadAction< + PayloadWithId<{ modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig | null }> + > + ) => { + const { id, modelConfig } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + + const oldModel = entity.ipAdapter.model; + + // First set the new model + entity.ipAdapter.model = modelConfig ? zModelIdentifierField.parse(modelConfig) : null; + + if (!entity.ipAdapter.model) { + return; + } + + if (isEqual(oldModel, entity.ipAdapter.model)) { + // Nothing changed, so we don't need to do anything + return; + } + + // The type of ref image depends on the model. When the user switches the model, we rebuild the ref image. + // When we switch the model, we keep the image the same, but change the other parameters. + + if (entity.ipAdapter.model.base === 'chatgpt-4o') { + // Switching to chatgpt-4o ref image + entity.ipAdapter = { + ...initialChatGPT4oReferenceImage, + image: entity.ipAdapter.image, + model: entity.ipAdapter.model, + }; + return; + } + + if (entity.ipAdapter.model.type === 'flux_redux') { + // Switching to flux_redux + entity.ipAdapter = { + ...initialFLUXRedux, + image: entity.ipAdapter.image, + model: entity.ipAdapter.model, + }; + return; + } + + if (entity.ipAdapter.model.type === 'ip_adapter') { + // Switching to ip_adapter + entity.ipAdapter = { + ...initialIPAdapter, + image: entity.ipAdapter.image, + model: entity.ipAdapter.model, + }; + // Ensure that the IP Adapter model is compatible with the CLIP Vision model + if (entity.ipAdapter.model?.base === 'flux') { + entity.ipAdapter.clipVisionModel = 'ViT-L'; + } else if (entity.ipAdapter.clipVisionModel === 'ViT-L') { + // Fall back to ViT-H (ViT-G would also work) + entity.ipAdapter.clipVisionModel = 'ViT-H'; + } + return; + } + }, + referenceImageIPAdapterCLIPVisionModelChanged: ( + state, + action: PayloadAction> + ) => { + const { id, clipVisionModel } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + if (entity.ipAdapter.type !== 'ip_adapter') { + return; + } + entity.ipAdapter.clipVisionModel = clipVisionModel; + }, + referenceImageIPAdapterWeightChanged: (state, action: PayloadAction>) => { + const { id, weight } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + if (entity.ipAdapter.type !== 'ip_adapter') { + return; + } + entity.ipAdapter.weight = weight; + }, + referenceImageIPAdapterBeginEndStepPctChanged: ( + state, + action: PayloadAction> + ) => { + const { id, beginEndStepPct } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + if (entity.ipAdapter.type !== 'ip_adapter') { + return; + } + entity.ipAdapter.beginEndStepPct = beginEndStepPct; + }, + //#region Shared entity + entitySelected: (state, action: PayloadAction<{ id: string }>) => { + const { id } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + // Cannot select a non-existent entity + return; + } + state.selectedId = id; + }, + entityNameChanged: (state, action: PayloadAction>) => { + const { id, name } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + entity.name = name; + }, + entityDuplicated: (state, action: PayloadAction) => { + const { id } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + + const newEntity = deepClone(entity); + if (newEntity.name) { + newEntity.name = `${newEntity.name} (Copy)`; + } + newEntity.id = getPrefixedId('reference_image'); + state.entities.push(newEntity); + + state.selectedId = newEntity.id; + }, + entityIsEnabledToggled: (state, action: PayloadAction) => { + const { id } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + entity.isEnabled = !entity.isEnabled; + }, + entityIsLockedToggled: (state, action: PayloadAction) => { + const { id } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + entity.isLocked = !entity.isLocked; + }, + entityDeleted: (state, action: PayloadAction) => { + const { id } = action.payload; + + let selectedId: string | null = null; + const entities = state.entities; + const index = entities.findIndex((entity) => entity.id === id); + const nextIndex = entities.length > 1 ? (index + 1) % entities.length : -1; + if (nextIndex !== -1) { + const nextEntity = entities[nextIndex]; + if (nextEntity) { + selectedId = nextEntity.id; + } + } + state.entities = state.entities.filter((rg) => rg.id !== id); + state.selectedId = selectedId; + }, + refImagesReset: () => getInitialRefImagesState(), + }, + extraReducers(builder) { + builder.addCase(canvasMetadataRecalled, (state, action) => { + const { referenceImages } = action.payload; + state.entities = referenceImages; + }); + }, +}); + +export const { + referenceImageAdded, + // referenceImageRecalled, + referenceImageIPAdapterImageChanged, + referenceImageIPAdapterMethodChanged, + referenceImageIPAdapterModelChanged, + referenceImageIPAdapterCLIPVisionModelChanged, + referenceImageIPAdapterWeightChanged, + referenceImageIPAdapterBeginEndStepPctChanged, + referenceImageIPAdapterFLUXReduxImageInfluenceChanged, +} = refImagesSlice.actions; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +const migrate = (state: any): any => { + return state; +}; + +export const refImagesPersistConfig: PersistConfig = { + name: refImagesSlice.name, + initialState: getInitialRefImagesState(), + migrate, + persistDenylist: [], +}; + +export const selectRefImagesSlice = (state: RootState) => state.refImages; + +export const selectReferenceImageEntities = createSelector(selectRefImagesSlice, (state) => state.entities); +export const selectActiveReferenceImageEntities = createSelector(selectReferenceImageEntities, (entities) => + entities.filter((e) => e.isEnabled) +); +export const selectRefImageEntityIds = createMemoizedSelector(selectReferenceImageEntities, (entities) => + entities.map((e) => e.id) +); +export const selectRefImageEntity = (state: RefImagesState, id: string) => + state.entities.find((entity) => entity.id === id) ?? null; + +export function selectRefImageEntityOrThrow( + state: RefImagesState, + id: string, + caller: string +): CanvasReferenceImageState { + const entity = selectRefImageEntity(state, id); + assert(entity, `Entity with id ${id} not found in ${caller}`); + return entity; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts index cffa517fd7..4e52436c6f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/selectors.ts @@ -2,17 +2,16 @@ import type { Selector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectReferenceImageEntities } from 'features/controlLayers/store/refImagesSlice'; import type { CanvasControlLayerState, CanvasEntityIdentifier, CanvasEntityState, + CanvasEntityType, CanvasInpaintMaskState, CanvasMetadata, CanvasRasterLayerState, CanvasRegionalGuidanceState, - CanvasRenderableEntityIdentifier, - CanvasRenderableEntityState, - CanvasRenderableEntityType, CanvasState, } from 'features/controlLayers/store/types'; import { getGridSize, getOptimalDimension } from 'features/parameters/util/optimalDimension'; @@ -40,14 +39,13 @@ export const createCanvasSelector = (selector: Selector) => const selectEntityCountAll = createCanvasSelector((canvas) => { return ( canvas.regionalGuidance.entities.length + - canvas.referenceImages.entities.length + canvas.rasterLayers.entities.length + canvas.controlLayers.entities.length + canvas.inpaintMasks.entities.length ); }); -const isVisibleEntity = (entity: CanvasRenderableEntityState) => entity.isEnabled && entity.objects.length > 0; +const isVisibleEntity = (entity: CanvasEntityState) => entity.isEnabled && entity.objects.length > 0; export const selectRasterLayerEntities = createCanvasSelector((canvas) => canvas.rasterLayers.entities); export const selectActiveRasterLayerEntities = createSelector(selectRasterLayerEntities, (entities) => @@ -69,11 +67,6 @@ export const selectActiveRegionalGuidanceEntities = createSelector(selectRegiona entities.filter(isVisibleEntity) ); -export const selectReferenceImageEntities = createCanvasSelector((canvas) => canvas.referenceImages.entities); -export const selectActiveReferenceImageEntities = createSelector(selectReferenceImageEntities, (entities) => - entities.filter((e) => e.isEnabled) -); - /** * Selects the total _active_ canvas entity count: * - Regions @@ -89,20 +82,17 @@ export const selectEntityCountActive = createSelector( selectActiveControlLayerEntities, selectActiveInpaintMaskEntities, selectActiveRegionalGuidanceEntities, - selectActiveReferenceImageEntities, ( activeRasterLayerEntities, activeControlLayerEntities, activeInpaintMaskEntities, - activeRegionalGuidanceEntities, - activeIPAdapterEntities + activeRegionalGuidanceEntities ) => { return ( activeRasterLayerEntities.length + activeControlLayerEntities.length + activeInpaintMaskEntities.length + - activeRegionalGuidanceEntities.length + - activeIPAdapterEntities.length + activeRegionalGuidanceEntities.length ); } ); @@ -153,9 +143,6 @@ export function selectEntity( case 'regional_guidance': entity = state.regionalGuidance.entities.find((entity) => entity.id === id); break; - case 'reference_image': - entity = state.referenceImages.entities.find((entity) => entity.id === id); - break; } // This cast is safe, but TS seems to be unable to infer the type @@ -165,13 +152,13 @@ export function selectEntity( /** * Selects the entity identifier for the entity that is below the given entity in terms of draw order. */ -export function selectEntityIdentifierBelowThisOne( +export function selectEntityIdentifierBelowThisOne( state: CanvasState, entityIdentifier: T ): Extract | undefined { const { id, type } = entityIdentifier; - let entities: CanvasRenderableEntityState[]; + let entities: CanvasEntityState[]; switch (type) { case 'raster_layer': { @@ -244,9 +231,6 @@ export function selectAllEntitiesOfType( case 'regional_guidance': entities = state.regionalGuidance.entities; break; - case 'reference_image': - entities = state.referenceImages.entities; - break; } // This cast is safe, but TS seems to be unable to infer the type @@ -259,7 +243,6 @@ export function selectAllEntitiesOfType( export function selectAllEntities(state: CanvasState): CanvasEntityState[] { // These are in the same order as they are displayed in the list! return [ - ...state.referenceImages.entities.toReversed(), ...state.inpaintMasks.entities.toReversed(), ...state.regionalGuidance.entities.toReversed(), ...state.controlLayers.entities.toReversed(), @@ -340,7 +323,7 @@ const selectRegionalGuidanceIsHidden = createCanvasSelector((canvas) => canvas.r /** * Returns the hidden selector for the given entity type. */ -export const getSelectIsTypeHidden = (type: CanvasRenderableEntityType) => { +export const getSelectIsTypeHidden = (type: CanvasEntityType) => { switch (type) { case 'raster_layer': return selectRasterLayersIsHidden; @@ -379,9 +362,6 @@ export const buildSelectHasObjects = (entityIdentifier: CanvasEntityIdentifier) if (!entity) { return false; } - if (entity.type === 'reference_image') { - return entity.ipAdapter.image !== null; - } return entity.objects.length > 0; }); }; @@ -397,9 +377,10 @@ export const selectBboxModelBase = createSelector(selectBbox, (bbox) => bbox.mod export const selectCanvasMetadata = createSelector( selectCanvasSlice, - (canvas): { canvas_v2_metadata: CanvasMetadata } => { + selectReferenceImageEntities, + (canvas, refImageEntities): { canvas_v2_metadata: CanvasMetadata } => { const canvas_v2_metadata: CanvasMetadata = { - referenceImages: selectAllEntitiesOfType(canvas, 'reference_image'), + referenceImages: refImageEntities, controlLayers: selectAllEntitiesOfType(canvas, 'control_layer'), inpaintMasks: selectAllEntitiesOfType(canvas, 'inpaint_mask'), rasterLayers: selectAllEntitiesOfType(canvas, 'raster_layer'), diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 83912c5b4d..8914e5805c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -408,25 +408,14 @@ const zCanvasEntityState = z.discriminatedUnion('type', [ zCanvasControlLayerState, zCanvasRegionalGuidanceState, zCanvasInpaintMaskState, - zCanvasReferenceImageState, ]); export type CanvasEntityState = z.infer; -const zCanvasRenderableEntityState = z.discriminatedUnion('type', [ - zCanvasRasterLayerState, - zCanvasControlLayerState, - zCanvasRegionalGuidanceState, - zCanvasInpaintMaskState, -]); -export type CanvasRenderableEntityState = z.infer; -export type CanvasRenderableEntityType = CanvasRenderableEntityState['type']; - const zCanvasEntityType = z.union([ zCanvasRasterLayerState.shape.type, zCanvasControlLayerState.shape.type, zCanvasRegionalGuidanceState.shape.type, zCanvasInpaintMaskState.shape.type, - zCanvasReferenceImageState.shape.type, ]); export type CanvasEntityType = z.infer; @@ -435,7 +424,7 @@ export const zCanvasEntityIdentifer = z.object({ type: zCanvasEntityType, }); export type CanvasEntityIdentifier = { id: string; type: T }; -export type CanvasRenderableEntityIdentifier = CanvasEntityIdentifier; + export type LoRA = { id: string; isEnabled: boolean; @@ -570,9 +559,6 @@ const zRegionalGuidance = z.object({ isHidden: z.boolean(), entities: z.array(zCanvasRegionalGuidanceState), }); -const zReferenceImages = z.object({ - entities: z.array(zCanvasReferenceImageState), -}); const zCanvasState = z.object({ _version: z.literal(3).default(3), selectedEntityIdentifier: zCanvasEntityIdentifer.nullable().default(null), @@ -581,7 +567,6 @@ const zCanvasState = z.object({ rasterLayers: zRasterLayers.default({ isHidden: false, entities: [] }), controlLayers: zControlLayers.default({ isHidden: false, entities: [] }), regionalGuidance: zRegionalGuidance.default({ isHidden: false, entities: [] }), - referenceImages: zReferenceImages.default({ entities: [] }), bbox: zBboxState.default({ rect: { x: 0, y: 0, width: 512, height: 512 }, aspectRatio: DEFAULT_ASPECT_RATIO_CONFIG, @@ -592,6 +577,14 @@ const zCanvasState = z.object({ }); export type CanvasState = z.infer; +const zRefImagesState = z.object({ + selectedId: zId.nullable().default(null), + entities: z.array(zCanvasReferenceImageState).default(() => []), +}); +export type RefImagesState = z.infer; +const INITIAL_REF_IMAGES_STATE = zRefImagesState.parse({}); +export const getInitialRefImagesState = () => deepClone(INITIAL_REF_IMAGES_STATE); + /** * Gets a fresh canvas initial state with no references in memory to existing objects. */ @@ -657,17 +650,6 @@ export type GenerationMode = 'txt2img' | 'img2img' | 'inpaint' | 'outpaint'; export type CanvasEntityStateFromType = Extract; -export function isRenderableEntityType( - entityType: CanvasEntityState['type'] -): entityType is CanvasRenderableEntityState['type'] { - return ( - entityType === 'raster_layer' || - entityType === 'control_layer' || - entityType === 'regional_guidance' || - entityType === 'inpaint_mask' - ); -} - export function isRasterLayerEntityIdentifier( entityIdentifier: CanvasEntityIdentifier ): entityIdentifier is CanvasEntityIdentifier<'raster_layer'> { @@ -725,16 +707,6 @@ export function isSaveableEntityIdentifier( return isRasterLayerEntityIdentifier(entityIdentifier) || isControlLayerEntityIdentifier(entityIdentifier); } -export function isRenderableEntity(entity: CanvasEntityState): entity is CanvasRenderableEntityState { - return isRenderableEntityType(entity.type); -} - -export function isRenderableEntityIdentifier( - entityIdentifier: CanvasEntityIdentifier -): entityIdentifier is CanvasRenderableEntityIdentifier { - return isRenderableEntityType(entityIdentifier.type); -} - export const getEntityIdentifier = ( entity: Extract ): CanvasEntityIdentifier => { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts index a4fbfffd02..8cebbe4846 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -21,6 +21,7 @@ import type { import { merge } from 'lodash-es'; import type { ImageDTO } from 'services/api/types'; import { assert } from 'tsafe'; +import type { PartialDeep } from 'type-fest'; export const imageDTOToImageObject = (imageDTO: ImageDTO, overrides?: Partial): CanvasImageState => { const { width, height, image_name } = imageDTO; @@ -127,7 +128,7 @@ export const initialControlLoRA: ControlLoRAConfig = { export const getReferenceImageState = ( id: string, - overrides?: Partial + overrides?: PartialDeep ): CanvasReferenceImageState => { const entityState: CanvasReferenceImageState = { id, @@ -143,7 +144,7 @@ export const getReferenceImageState = ( export const getRegionalGuidanceState = ( id: string, - overrides?: Partial + overrides?: PartialDeep ): CanvasRegionalGuidanceState => { const entityState: CanvasRegionalGuidanceState = { id, @@ -169,7 +170,7 @@ export const getRegionalGuidanceState = ( export const getControlLayerState = ( id: string, - overrides?: Partial + overrides?: PartialDeep ): CanvasControlLayerState => { const entityState: CanvasControlLayerState = { id, @@ -189,7 +190,7 @@ export const getControlLayerState = ( export const getRasterLayerState = ( id: string, - overrides?: Partial + overrides?: PartialDeep ): CanvasRasterLayerState => { const entityState: CanvasRasterLayerState = { id, @@ -207,7 +208,7 @@ export const getRasterLayerState = ( export const getInpaintMaskState = ( id: string, - overrides?: Partial + overrides?: PartialDeep ): CanvasInpaintMaskState => { const entityState: CanvasInpaintMaskState = { id, @@ -232,7 +233,7 @@ export const getInpaintMaskState = ( const convertRasterLayerToControlLayer = ( newId: string, rasterLayerState: CanvasRasterLayerState, - overrides?: Partial + overrides?: PartialDeep ): CanvasControlLayerState => { const { name, objects, position } = rasterLayerState; const controlLayerState = getControlLayerState(newId, { @@ -247,7 +248,7 @@ const convertRasterLayerToControlLayer = ( const convertRasterLayerToInpaintMask = ( newId: string, rasterLayerState: CanvasRasterLayerState, - overrides?: Partial + overrides?: PartialDeep ): CanvasInpaintMaskState => { const { name, objects, position } = rasterLayerState; const inpaintMaskState = getInpaintMaskState(newId, { @@ -262,7 +263,7 @@ const convertRasterLayerToInpaintMask = ( const convertRasterLayerToRegionalGuidance = ( newId: string, rasterLayerState: CanvasRasterLayerState, - overrides?: Partial + overrides?: PartialDeep ): CanvasRegionalGuidanceState => { const { name, objects, position } = rasterLayerState; const regionalGuidanceState = getRegionalGuidanceState(newId, { @@ -277,7 +278,7 @@ const convertRasterLayerToRegionalGuidance = ( const convertControlLayerToRasterLayer = ( newId: string, controlLayerState: CanvasControlLayerState, - overrides?: Partial + overrides?: PartialDeep ): CanvasRasterLayerState => { const { name, objects, position } = controlLayerState; const rasterLayerState = getRasterLayerState(newId, { @@ -292,7 +293,7 @@ const convertControlLayerToRasterLayer = ( const convertControlLayerToInpaintMask = ( newId: string, rasterLayerState: CanvasControlLayerState, - overrides?: Partial + overrides?: PartialDeep ): CanvasInpaintMaskState => { const { name, objects, position } = rasterLayerState; const inpaintMaskState = getInpaintMaskState(newId, { @@ -307,7 +308,7 @@ const convertControlLayerToInpaintMask = ( const convertControlLayerToRegionalGuidance = ( newId: string, rasterLayerState: CanvasControlLayerState, - overrides?: Partial + overrides?: PartialDeep ): CanvasRegionalGuidanceState => { const { name, objects, position } = rasterLayerState; const regionalGuidanceState = getRegionalGuidanceState(newId, { @@ -322,7 +323,7 @@ const convertControlLayerToRegionalGuidance = ( const convertInpaintMaskToRegionalGuidance = ( newId: string, inpaintMaskState: CanvasInpaintMaskState, - overrides?: Partial + overrides?: PartialDeep ): CanvasRegionalGuidanceState => { const { name, objects, position } = inpaintMaskState; const regionalGuidanceState = getRegionalGuidanceState(newId, { @@ -337,7 +338,7 @@ const convertInpaintMaskToRegionalGuidance = ( const convertRegionalGuidanceToInpaintMask = ( newId: string, regionalGuidanceState: CanvasRegionalGuidanceState, - overrides?: Partial + overrides?: PartialDeep ): CanvasInpaintMaskState => { const { name, objects, position } = regionalGuidanceState; const inpaintMaskState = getInpaintMaskState(newId, { diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts index 1d88a77edc..6f4fd19571 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts @@ -1,9 +1,14 @@ import { useStore } from '@nanostores/react'; import { getStore, useAppStore } from 'app/store/nanostores/store'; import type { AppDispatch, AppGetState, RootState } from 'app/store/store'; -import { entityDeleted, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice'; +import { entityDeleted } from 'features/controlLayers/store/canvasSlice'; +import { + referenceImageIPAdapterImageChanged, + selectReferenceImageEntities, + selectRefImagesSlice, +} from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import { type CanvasState, getEntityIdentifier } from 'features/controlLayers/store/types'; +import type { CanvasState, RefImagesState } from 'features/controlLayers/store/types'; import type { ImageUsage } from 'features/deleteImageModal/store/types'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageSelected } from 'features/gallery/store/gallerySlice'; @@ -145,8 +150,9 @@ const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): Im const nodes = selectNodesSlice(state); const canvas = selectCanvasSlice(state); const upscale = selectUpscaleSlice(state); + const refImages = selectRefImagesSlice(state); - return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, image_name)); + return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, refImages, image_name)); }; const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({ @@ -221,9 +227,9 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image }; const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { - selectCanvasSlice(state).referenceImages.entities.forEach((entity) => { + selectReferenceImageEntities(state).forEach((entity) => { if (entity.ipAdapter.image?.image_name === imageDTO.image_name) { - dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: getEntityIdentifier(entity), imageDTO: null })); + dispatch(referenceImageIPAdapterImageChanged({ id: entity.id, imageDTO: null })); } }); }; @@ -243,7 +249,13 @@ const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageD }); }; -export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: UpscaleState, image_name: string) => { +export const getImageUsage = ( + nodes: NodesState, + canvas: CanvasState, + upscale: UpscaleState, + refImages: RefImagesState, + image_name: string +) => { const isNodesImage = nodes.nodes.filter(isInvocationNode).some((node) => some(node.data.inputs, (input) => { if (isImageFieldInputInstance(input)) { @@ -264,9 +276,7 @@ export const getImageUsage = (nodes: NodesState, canvas: CanvasState, upscale: U const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name; - const isReferenceImage = canvas.referenceImages.entities.some( - ({ ipAdapter }) => ipAdapter.image?.image_name === image_name - ); + const isReferenceImage = refImages.entities.some(({ ipAdapter }) => ipAdapter.image?.image_name === image_name); const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) => objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index 89abd63a77..925a118694 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -1,11 +1,7 @@ import { logger } from 'app/logging/logger'; import type { AppDispatch, RootState } from 'app/store/store'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import type { - CanvasEntityIdentifier, - CanvasEntityType, - CanvasRenderableEntityIdentifier, -} from 'features/controlLayers/store/types'; +import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types'; import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; import type { BoardId } from 'features/gallery/store/types'; import { @@ -133,7 +129,7 @@ const _setGlobalReferenceImage = buildTypeAndKey('set-global-reference-image'); export type SetGlobalReferenceImageDndTargetData = DndData< typeof _setGlobalReferenceImage.type, typeof _setGlobalReferenceImage.key, - { entityIdentifier: CanvasEntityIdentifier<'reference_image'> } + { id: string } >; export const setGlobalReferenceImageDndTarget: DndTarget< SetGlobalReferenceImageDndTargetData, @@ -150,8 +146,8 @@ export const setGlobalReferenceImageDndTarget: DndTarget< }, handler: ({ sourceData, targetData, dispatch }) => { const { imageDTO } = sourceData.payload; - const { entityIdentifier } = targetData.payload; - setGlobalReferenceImage({ entityIdentifier, imageDTO, dispatch }); + const { id } = targetData.payload; + setGlobalReferenceImage({ id, imageDTO, dispatch }); }, }; //#endregion @@ -352,7 +348,7 @@ type NewCanvasFromImageDndTargetData = DndData< typeof _newCanvas.type, typeof _newCanvas.key, { - type: CanvasEntityType | 'regional_guidance_with_reference_image'; + type: CanvasEntityType | 'regional_guidance_with_reference_image' | 'reference_image'; withResize?: boolean; withInpaintMask?: boolean; } @@ -379,7 +375,7 @@ const _replaceCanvasEntityObjectsWithImage = buildTypeAndKey('replace-canvas-ent export type ReplaceCanvasEntityObjectsWithImageDndTargetData = DndData< typeof _replaceCanvasEntityObjectsWithImage.type, typeof _replaceCanvasEntityObjectsWithImage.key, - { entityIdentifier: CanvasRenderableEntityIdentifier } + { entityIdentifier: CanvasEntityIdentifier } >; export const replaceCanvasEntityObjectsWithImageDndTarget: DndTarget< ReplaceCanvasEntityObjectsWithImageDndTargetData, diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx index 3247f7e0a0..fa016a6b46 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx @@ -15,6 +15,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage'; import { getImageUsage } from 'features/deleteImageModal/store/state'; @@ -54,23 +55,26 @@ const DeleteBoardModal = () => { const selectImageUsageSummary = useMemo( () => - createMemoizedSelector([selectNodesSlice, selectCanvasSlice, selectUpscaleSlice], (nodes, canvas, upscale) => { - const allImageUsage = (boardImageNames ?? []).map((imageName) => - getImageUsage(nodes, canvas, upscale, imageName) - ); + createMemoizedSelector( + [selectNodesSlice, selectCanvasSlice, selectUpscaleSlice, selectRefImagesSlice], + (nodes, canvas, upscale, refImages) => { + const allImageUsage = (boardImageNames ?? []).map((imageName) => + getImageUsage(nodes, canvas, upscale, refImages, imageName) + ); - const imageUsageSummary: ImageUsage = { - isUpscaleImage: some(allImageUsage, (i) => i.isUpscaleImage), - isRasterLayerImage: some(allImageUsage, (i) => i.isRasterLayerImage), - isInpaintMaskImage: some(allImageUsage, (i) => i.isInpaintMaskImage), - isRegionalGuidanceImage: some(allImageUsage, (i) => i.isRegionalGuidanceImage), - isNodesImage: some(allImageUsage, (i) => i.isNodesImage), - isControlLayerImage: some(allImageUsage, (i) => i.isControlLayerImage), - isReferenceImage: some(allImageUsage, (i) => i.isReferenceImage), - }; + const imageUsageSummary: ImageUsage = { + isUpscaleImage: some(allImageUsage, (i) => i.isUpscaleImage), + isRasterLayerImage: some(allImageUsage, (i) => i.isRasterLayerImage), + isInpaintMaskImage: some(allImageUsage, (i) => i.isInpaintMaskImage), + isRegionalGuidanceImage: some(allImageUsage, (i) => i.isRegionalGuidanceImage), + isNodesImage: some(allImageUsage, (i) => i.isNodesImage), + isControlLayerImage: some(allImageUsage, (i) => i.isControlLayerImage), + isReferenceImage: some(allImageUsage, (i) => i.isReferenceImage), + }; - return imageUsageSummary; - }), + return imageUsageSummary; + } + ), [boardImageNames] ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx index 4038aa4aff..fa20556c5d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx @@ -3,6 +3,8 @@ import { useAppStore } from 'app/store/nanostores/store'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { referenceImageAdded } from 'features/controlLayers/store/refImagesSlice'; +import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { sentImageToCanvas } from 'features/gallery/store/actions'; @@ -74,8 +76,8 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { }, [imageDTO, imageViewer, store, t]); const onClickNewGlobalReferenceImageFromImage = useCallback(() => { - const { dispatch, getState } = store; - createNewCanvasEntityFromImage({ imageDTO, type: 'reference_image', dispatch, getState }); + const { dispatch } = store; + dispatch(referenceImageAdded({ overrides: { ipAdapter: { image: imageDTOToImageWithDims(imageDTO) } } })); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); imageViewer.close(); diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index b73577bbdf..81fe6e9c44 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -10,23 +10,21 @@ import { entityRasterized, inpaintMaskAdded, rasterLayerAdded, - referenceImageAdded, - referenceImageIPAdapterImageChanged, rgAdded, rgIPAdapterImageChanged, } from 'features/controlLayers/store/canvasSlice'; import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { referenceImageAdded, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/refImagesSlice'; import { selectBboxModelBase, selectBboxRect } from 'features/controlLayers/store/selectors'; import type { CanvasControlLayerState, CanvasEntityIdentifier, + CanvasEntityState, CanvasEntityType, CanvasImageState, CanvasInpaintMaskState, CanvasRasterLayerState, CanvasRegionalGuidanceState, - CanvasRenderableEntityIdentifier, - CanvasRenderableEntityState, } from 'features/controlLayers/store/types'; import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util'; import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; @@ -41,13 +39,9 @@ import type { ImageDTO } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; -export const setGlobalReferenceImage = (arg: { - imageDTO: ImageDTO; - entityIdentifier: CanvasEntityIdentifier<'reference_image'>; - dispatch: AppDispatch; -}) => { - const { imageDTO, entityIdentifier, dispatch } = arg; - dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); +export const setGlobalReferenceImage = (arg: { imageDTO: ImageDTO; id: string; dispatch: AppDispatch }) => { + const { imageDTO, id, dispatch } = arg; + dispatch(referenceImageIPAdapterImageChanged({ id, imageDTO })); }; export const setRegionalGuidanceReferenceImage = (arg: { @@ -84,7 +78,7 @@ export const createNewCanvasEntityFromImage = (arg: { type: CanvasEntityType | 'regional_guidance_with_reference_image'; dispatch: AppDispatch; getState: () => RootState; - overrides?: Partial>; + overrides?: Partial>; }) => { const { type, imageDTO, dispatch, getState, overrides: _overrides } = arg; const state = getState(); @@ -117,12 +111,6 @@ export const createNewCanvasEntityFromImage = (arg: { dispatch(rgAdded({ overrides, isSelected: true })); break; } - case 'reference_image': { - const ipAdapter = deepClone(selectDefaultRefImageConfig(getState())); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true })); - break; - } case 'regional_guidance_with_reference_image': { const ipAdapter = deepClone(selectDefaultIPAdapter(getState())); ipAdapter.image = imageDTOToImageWithDims(imageDTO); @@ -146,7 +134,7 @@ export const createNewCanvasEntityFromImage = (arg: { */ export const newCanvasFromImage = async (arg: { imageDTO: ImageDTO; - type: CanvasEntityType | 'regional_guidance_with_reference_image'; + type: CanvasEntityType | 'regional_guidance_with_reference_image' | 'reference_image'; withResize?: boolean; withInpaintMask?: boolean; dispatch: AppDispatch; @@ -283,7 +271,7 @@ export const newCanvasFromImage = async (arg: { export const replaceCanvasEntityObjectsWithImage = (arg: { imageDTO: ImageDTO; - entityIdentifier: CanvasRenderableEntityIdentifier; + entityIdentifier: CanvasEntityIdentifier; dispatch: AppDispatch; getState: () => RootState; }) => { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index e6a4791587..09a24b9669 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -3,6 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isChatGPT4oAspectRatioID, isChatGPT4oReferenceImageConfig } from 'features/controlLayers/store/types'; import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators'; @@ -32,6 +33,7 @@ export const buildChatGPT4oGraph = async ( const model = selectMainModelConfig(state); const canvas = selectCanvasSlice(state); + const refImages = selectRefImagesSlice(state); const { bbox } = canvas; const { positivePrompt } = selectPresetModifiedPrompts(state); @@ -41,7 +43,7 @@ export const buildChatGPT4oGraph = async ( assert(isChatGPT4oAspectRatioID(bbox.aspectRatio.id), 'ChatGPT 4o does not support this aspect ratio'); - const validRefImages = canvas.referenceImages.entities + const validRefImages = refImages.entities .filter((entity) => entity.isEnabled) .filter((entity) => isChatGPT4oReferenceImageConfig(entity.ipAdapter)) .filter((entity) => getGlobalReferenceImageWarnings(entity, model).length === 0) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts index 2bbfe1d37e..fd97936f67 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts @@ -3,6 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addFLUXFill } from 'features/nodes/util/graph/generation/addFLUXFill'; import { addFLUXLoRAs } from 'features/nodes/util/graph/generation/addFLUXLoRAs'; @@ -42,6 +43,7 @@ export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | const params = selectParamsSlice(state); const canvas = selectCanvasSlice(state); + const refImages = selectRefImagesSlice(state); const { bbox } = canvas; @@ -271,7 +273,7 @@ export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | id: getPrefixedId('ip_adapter_collector'), }); const ipAdapterResult = addIPAdapters({ - entities: canvas.referenceImages.entities, + entities: refImages.entities, g, collector: ipAdapterCollect, model, @@ -284,7 +286,7 @@ export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | id: getPrefixedId('ip_adapter_collector'), }); const fluxReduxResult = addFLUXReduxes({ - entities: canvas.referenceImages.entities, + entities: refImages.entities, g, collector: fluxReduxCollect, model, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index 0bf32670b4..8ba379ec43 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -3,6 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addControlNets, addT2IAdapters } from 'features/nodes/util/graph/generation/addControlAdapters'; import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage'; @@ -37,6 +38,7 @@ export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | const params = selectParamsSlice(state); const canvas = selectCanvasSlice(state); + const refImages = selectRefImagesSlice(state); const { bbox } = canvas; const model = selectMainModelConfig(state); @@ -265,7 +267,7 @@ export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | id: getPrefixedId('ip_adapter_collector'), }); const ipAdapterResult = addIPAdapters({ - entities: canvas.referenceImages.entities, + entities: refImages.entities, g, collector: ipAdapterCollect, model, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index 1960b6ab8b..f091fa4411 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -3,6 +3,7 @@ import type { RootState } from 'app/store/store'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasMetadata, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { addControlNets, addT2IAdapters } from 'features/nodes/util/graph/generation/addControlAdapters'; import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage'; @@ -41,6 +42,7 @@ export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | const params = selectParamsSlice(state); const canvas = selectCanvasSlice(state); + const refImages = selectRefImagesSlice(state); const { bbox } = canvas; @@ -272,7 +274,7 @@ export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | id: getPrefixedId('ip_adapter_collector'), }); const ipAdapterResult = addIPAdapters({ - entities: canvas.referenceImages.entities, + entities: refImages.entities, g, collector: ipAdapterCollect, model, diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx index 037c1021fb..f6f71927c8 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx @@ -1,6 +1,7 @@ import { Box, Textarea } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize'; +import { RefImageList } from 'features/controlLayers/components/IPAdapter/IPAdapterList'; import { positivePromptChanged, selectBase, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { ShowDynamicPromptsPreviewButton } from 'features/dynamicPrompts/components/ShowDynamicPromptsPreviewButton'; import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel'; @@ -107,6 +108,7 @@ export const ParamPositivePrompt = memo(() => { label={`${t('parameters.positivePromptPlaceholder')} (${t('stylePresets.preview')})`} /> )} + ); diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index f17542a4df..2355feed84 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -9,8 +9,9 @@ import type { AppConfig } from 'app/types/invokeai'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { CanvasState, ParamsState } from 'features/controlLayers/store/types'; +import type { CanvasState, ParamsState, RefImagesState } from 'features/controlLayers/store/types'; import { getControlLayerWarnings, getGlobalReferenceImageWarnings, @@ -75,6 +76,7 @@ const debouncedUpdateReasons = debounce( isConnected: boolean, canvas: CanvasState, params: ParamsState, + refImages: RefImagesState, dynamicPrompts: DynamicPromptsState, canvasIsFiltering: boolean, canvasIsTransforming: boolean, @@ -97,6 +99,7 @@ const debouncedUpdateReasons = debounce( model, canvas, params, + refImages, dynamicPrompts, canvasIsFiltering, canvasIsTransforming, @@ -138,6 +141,7 @@ export const useReadinessWatcher = () => { const tab = useAppSelector(selectActiveTab); const canvas = useAppSelector(selectCanvasSlice); const params = useAppSelector(selectParamsSlice); + const refImages = useAppSelector(selectRefImagesSlice); const dynamicPrompts = useAppSelector(selectDynamicPromptsSlice); const nodes = useAppSelector(selectNodesSlice); const workflowSettings = useAppSelector(selectWorkflowSettingsSlice); @@ -159,6 +163,7 @@ export const useReadinessWatcher = () => { isConnected, canvas, params, + refImages, dynamicPrompts, canvasIsFiltering, canvasIsTransforming, @@ -177,6 +182,7 @@ export const useReadinessWatcher = () => { }, [ store, canvas, + refImages, canvasIsCompositing, canvasIsFiltering, canvasIsRasterizing, @@ -334,6 +340,7 @@ const getReasonsWhyCannotEnqueueCanvasTab = (arg: { model: MainModelConfig | null | undefined; canvas: CanvasState; params: ParamsState; + refImages: RefImagesState; dynamicPrompts: DynamicPromptsState; canvasIsFiltering: boolean; canvasIsTransforming: boolean; @@ -347,6 +354,7 @@ const getReasonsWhyCannotEnqueueCanvasTab = (arg: { model, canvas, params, + refImages, dynamicPrompts, canvasIsFiltering, canvasIsTransforming, @@ -514,24 +522,21 @@ const getReasonsWhyCannotEnqueueCanvasTab = (arg: { } }); - const enabledGlobalReferenceLayers = canvas.referenceImages.entities.filter( - (referenceImage) => referenceImage.isEnabled - ); - // Flux Kontext only supports 1x Reference Image at a time. - const referenceImageCount = enabledGlobalReferenceLayers.length; + const referenceImageCount = refImages.entities.filter((entity) => entity.isEnabled).length; if (model?.base === 'flux-kontext' && referenceImageCount > 1) { reasons.push({ content: i18n.t('parameters.invoke.fluxKontextMultipleReferenceImages') }); } - canvas.referenceImages.entities + refImages.entities .filter((entity) => entity.isEnabled) .forEach((entity, i) => { const layerLiteral = i18n.t('controlLayers.layer_one'); const layerNumber = i + 1; const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]); const prefix = `${layerLiteral} #${layerNumber} (${layerType})`; + const problems = getGlobalReferenceImageWarnings(entity, model); if (problems.length) { From 5a2f5c105d658946c181dcc20f66257b396f1349 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 13 Jun 2025 12:22:02 +1000 Subject: [PATCH 087/210] refactor(ui): `refImage.ipAdapter` -> `refImage.config` --- .../listeners/modelsLoaded.ts | 34 +-- .../components/IPAdapter/IPAdapterPreview.tsx | 2 +- .../IPAdapter/IPAdapterSettings.tsx | 36 +-- .../RegionalGuidanceIPAdapterSettings.tsx | 69 +++--- ...nalGuidanceIPAdapterSettingsEmptyState.tsx | 4 +- .../controlLayers/hooks/addLayerHooks.ts | 16 +- .../controlLayers/hooks/saveCanvasHooks.ts | 22 +- .../controlLayers/store/canvasSlice.ts | 126 +++++----- .../controlLayers/store/refImagesSlice.ts | 218 ++++++------------ .../src/features/controlLayers/store/types.ts | 27 ++- .../src/features/controlLayers/store/util.ts | 15 +- .../controlLayers/store/validators.ts | 20 +- .../features/deleteImageModal/store/state.ts | 10 +- .../ImageMenuItemNewLayerFromImageSubMenu.tsx | 4 +- .../web/src/features/imageActions/actions.ts | 26 +-- .../web/src/features/metadata/util/parsers.ts | 20 +- .../util/graph/generation/addFLUXRedux.ts | 17 +- .../util/graph/generation/addIPAdapters.ts | 17 +- .../nodes/util/graph/generation/addRegions.ts | 19 +- .../graph/generation/buildChatGPT4oGraph.ts | 7 +- .../web/src/features/queue/store/readiness.ts | 26 +-- 21 files changed, 312 insertions(+), 423 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts index 8590c35810..35b6f8dac2 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -1,7 +1,7 @@ import { logger } from 'app/logging/logger'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { AppDispatch, RootState } from 'app/store/store'; -import { controlLayerModelChanged, rgIPAdapterModelChanged } from 'features/controlLayers/store/canvasSlice'; +import { controlLayerModelChanged, rgRefImageModelChanged } from 'features/controlLayers/store/canvasSlice'; import { loraDeleted } from 'features/controlLayers/store/lorasSlice'; import { clipEmbedModelSelected, @@ -11,9 +11,9 @@ import { t5EncoderModelSelected, vaeSelected, } from 'features/controlLayers/store/paramsSlice'; -import { referenceImageIPAdapterModelChanged, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; +import { refImageModelChanged, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { getEntityIdentifier, isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types'; import { modelSelected } from 'features/parameters/store/actions'; import { postProcessingModelChanged, upscaleModelChanged } from 'features/parameters/store/upscaleSlice'; import { @@ -208,11 +208,11 @@ const handleControlAdapterModels: ModelHandler = (models, state, dispatch, log) const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { const ipaModels = models.filter(isIPAdapterModelConfig); selectRefImagesSlice(state).entities.forEach((entity) => { - if (entity.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(entity.config)) { return; } - const selectedIPAdapterModel = entity.ipAdapter.model; + const selectedIPAdapterModel = entity.config.model; // `null` is a valid IP adapter model - no need to do anything. if (!selectedIPAdapterModel) { return; @@ -222,16 +222,16 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { return; } log.debug({ selectedIPAdapterModel }, 'Selected IP adapter model is not available, clearing'); - dispatch(referenceImageIPAdapterModelChanged({ id: entity.id, modelConfig: null })); + dispatch(refImageModelChanged({ id: entity.id, modelConfig: null })); }); selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => { - entity.referenceImages.forEach(({ id: referenceImageId, ipAdapter }) => { - if (ipAdapter.type !== 'ip_adapter') { + entity.referenceImages.forEach(({ id: referenceImageId, config }) => { + if (!isIPAdapterConfig(config)) { return; } - const selectedIPAdapterModel = ipAdapter.model; + const selectedIPAdapterModel = config.model; // `null` is a valid IP adapter model - no need to do anything. if (!selectedIPAdapterModel) { return; @@ -242,7 +242,7 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { } log.debug({ selectedIPAdapterModel }, 'Selected IP adapter model is not available, clearing'); dispatch( - rgIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null }) + rgRefImageModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null }) ); }); }); @@ -252,10 +252,10 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => { const fluxReduxModels = models.filter(isFluxReduxModelConfig); selectRefImagesSlice(state).entities.forEach((entity) => { - if (entity.ipAdapter.type !== 'flux_redux') { + if (!isFLUXReduxConfig(entity.config)) { return; } - const selectedFLUXReduxModel = entity.ipAdapter.model; + const selectedFLUXReduxModel = entity.config.model; // `null` is a valid FLUX Redux model - no need to do anything. if (!selectedFLUXReduxModel) { return; @@ -265,16 +265,16 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => { return; } log.debug({ selectedFLUXReduxModel }, 'Selected FLUX Redux model is not available, clearing'); - dispatch(referenceImageIPAdapterModelChanged({ id: entity.id, modelConfig: null })); + dispatch(refImageModelChanged({ id: entity.id, modelConfig: null })); }); selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => { - entity.referenceImages.forEach(({ id: referenceImageId, ipAdapter }) => { - if (ipAdapter.type !== 'flux_redux') { + entity.referenceImages.forEach(({ id: referenceImageId, config }) => { + if (!isFLUXReduxConfig(config)) { return; } - const selectedFLUXReduxModel = ipAdapter.model; + const selectedFLUXReduxModel = config.model; // `null` is a valid FLUX Redux model - no need to do anything. if (!selectedFLUXReduxModel) { return; @@ -285,7 +285,7 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => { } log.debug({ selectedFLUXReduxModel }, 'Selected FLUX Redux model is not available, clearing'); dispatch( - rgIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null }) + rgRefImageModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null }) ); }); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx index 58d6db2912..01b85fb5f1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx @@ -52,7 +52,7 @@ export const RefImagePreview = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index fe2cd460b7..1e884209b4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -11,13 +11,13 @@ import { IPAdapterSettingsEmptyState } from 'features/controlLayers/components/I import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { - referenceImageIPAdapterBeginEndStepPctChanged, - referenceImageIPAdapterCLIPVisionModelChanged, - referenceImageIPAdapterFLUXReduxImageInfluenceChanged, - referenceImageIPAdapterImageChanged, - referenceImageIPAdapterMethodChanged, - referenceImageIPAdapterModelChanged, - referenceImageIPAdapterWeightChanged, + refImageFLUXReduxImageInfluenceChanged, + refImageImageChanged, + refImageIPAdapterBeginEndStepPctChanged, + refImageIPAdapterCLIPVisionModelChanged, + refImageIPAdapterMethodChanged, + refImageIPAdapterWeightChanged, + refImageModelChanged, selectRefImageEntity, selectRefImageEntityOrThrow, selectRefImagesSlice, @@ -35,64 +35,64 @@ import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConf import { IPAdapterImagePreview } from './IPAdapterImagePreview'; -const buildSelectIPAdapter = (id: string) => +const buildSelectConfig = (id: string) => createSelector( selectRefImagesSlice, - (refImages) => selectRefImageEntityOrThrow(refImages, id, 'IPAdapterSettings').ipAdapter + (refImages) => selectRefImageEntityOrThrow(refImages, id, 'IPAdapterSettings').config ); const IPAdapterSettingsContent = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const id = useRefImageIdContext(); - const selectIPAdapter = useMemo(() => buildSelectIPAdapter(id), [id]); + const selectIPAdapter = useMemo(() => buildSelectConfig(id), [id]); const ipAdapter = useAppSelector(selectIPAdapter); const onChangeBeginEndStepPct = useCallback( (beginEndStepPct: [number, number]) => { - dispatch(referenceImageIPAdapterBeginEndStepPctChanged({ id, beginEndStepPct })); + dispatch(refImageIPAdapterBeginEndStepPctChanged({ id, beginEndStepPct })); }, [dispatch, id] ); const onChangeWeight = useCallback( (weight: number) => { - dispatch(referenceImageIPAdapterWeightChanged({ id, weight })); + dispatch(refImageIPAdapterWeightChanged({ id, weight })); }, [dispatch, id] ); const onChangeIPMethod = useCallback( (method: IPMethodV2) => { - dispatch(referenceImageIPAdapterMethodChanged({ id, method })); + dispatch(refImageIPAdapterMethodChanged({ id, method })); }, [dispatch, id] ); const onChangeFLUXReduxImageInfluence = useCallback( (imageInfluence: FLUXReduxImageInfluenceType) => { - dispatch(referenceImageIPAdapterFLUXReduxImageInfluenceChanged({ id, imageInfluence })); + dispatch(refImageFLUXReduxImageInfluenceChanged({ id, imageInfluence })); }, [dispatch, id] ); const onChangeModel = useCallback( (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => { - dispatch(referenceImageIPAdapterModelChanged({ id, modelConfig })); + dispatch(refImageModelChanged({ id, modelConfig })); }, [dispatch, id] ); const onChangeCLIPVisionModel = useCallback( (clipVisionModel: CLIPVisionModelV2) => { - dispatch(referenceImageIPAdapterCLIPVisionModelChanged({ id, clipVisionModel })); + dispatch(refImageIPAdapterCLIPVisionModelChanged({ id, clipVisionModel })); }, [dispatch, id] ); const onChangeImage = useCallback( (imageDTO: ImageDTO | null) => { - dispatch(referenceImageIPAdapterImageChanged({ id, imageDTO })); + dispatch(refImageImageChanged({ id, imageDTO })); }, [dispatch, id] ); @@ -156,7 +156,7 @@ IPAdapterSettingsContent.displayName = 'IPAdapterSettingsContent'; const buildSelectIPAdapterHasImage = (id: string) => createSelector(selectRefImagesSlice, (refImages) => { const referenceImage = selectRefImageEntity(refImages, id); - return !!referenceImage && referenceImage.ipAdapter.image !== null; + return !!referenceImage && referenceImage.config.image !== null; }); export const IPAdapterSettings = memo(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index bd5c7c544c..4cc434c439 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -13,14 +13,14 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti import { usePullBboxIntoRegionalGuidanceReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { - rgIPAdapterBeginEndStepPctChanged, - rgIPAdapterCLIPVisionModelChanged, - rgIPAdapterDeleted, - rgIPAdapterFLUXReduxImageInfluenceChanged, - rgIPAdapterImageChanged, - rgIPAdapterMethodChanged, - rgIPAdapterModelChanged, - rgIPAdapterWeightChanged, + rgRefImageDeleted, + rgRefImageFLUXReduxImageInfluenceChanged, + rgRefImageImageChanged, + rgRefImageIPAdapterBeginEndStepPctChanged, + rgRefImageIPAdapterCLIPVisionModelChanged, + rgRefImageIPAdapterMethodChanged, + rgRefImageIPAdapterWeightChanged, + rgRefImageModelChanged, } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors'; import type { @@ -46,64 +46,64 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro const { t } = useTranslation(); const dispatch = useAppDispatch(); const onDeleteIPAdapter = useCallback(() => { - dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId })); + dispatch(rgRefImageDeleted({ entityIdentifier, referenceImageId })); }, [dispatch, entityIdentifier, referenceImageId]); - const selectIPAdapter = useMemo( + const selectConfig = useMemo( () => createSelector(selectCanvasSlice, (canvas) => { const referenceImage = selectRegionalGuidanceReferenceImage(canvas, entityIdentifier, referenceImageId); assert(referenceImage, `Regional Guidance IP Adapter with id ${referenceImageId} not found`); - return referenceImage.ipAdapter; + return referenceImage.config; }), [entityIdentifier, referenceImageId] ); - const ipAdapter = useAppSelector(selectIPAdapter); + const config = useAppSelector(selectConfig); const onChangeBeginEndStepPct = useCallback( (beginEndStepPct: [number, number]) => { - dispatch(rgIPAdapterBeginEndStepPctChanged({ entityIdentifier, referenceImageId, beginEndStepPct })); + dispatch(rgRefImageIPAdapterBeginEndStepPctChanged({ entityIdentifier, referenceImageId, beginEndStepPct })); }, [dispatch, entityIdentifier, referenceImageId] ); const onChangeWeight = useCallback( (weight: number) => { - dispatch(rgIPAdapterWeightChanged({ entityIdentifier, referenceImageId, weight })); + dispatch(rgRefImageIPAdapterWeightChanged({ entityIdentifier, referenceImageId, weight })); }, [dispatch, entityIdentifier, referenceImageId] ); const onChangeIPMethod = useCallback( (method: IPMethodV2) => { - dispatch(rgIPAdapterMethodChanged({ entityIdentifier, referenceImageId, method })); + dispatch(rgRefImageIPAdapterMethodChanged({ entityIdentifier, referenceImageId, method })); }, [dispatch, entityIdentifier, referenceImageId] ); const onChangeFLUXReduxImageInfluence = useCallback( (imageInfluence: FLUXReduxImageInfluenceType) => { - dispatch(rgIPAdapterFLUXReduxImageInfluenceChanged({ entityIdentifier, referenceImageId, imageInfluence })); + dispatch(rgRefImageFLUXReduxImageInfluenceChanged({ entityIdentifier, referenceImageId, imageInfluence })); }, [dispatch, entityIdentifier, referenceImageId] ); const onChangeModel = useCallback( (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig) => { - dispatch(rgIPAdapterModelChanged({ entityIdentifier, referenceImageId, modelConfig })); + dispatch(rgRefImageModelChanged({ entityIdentifier, referenceImageId, modelConfig })); }, [dispatch, entityIdentifier, referenceImageId] ); const onChangeCLIPVisionModel = useCallback( (clipVisionModel: CLIPVisionModelV2) => { - dispatch(rgIPAdapterCLIPVisionModelChanged({ entityIdentifier, referenceImageId, clipVisionModel })); + dispatch(rgRefImageIPAdapterCLIPVisionModelChanged({ entityIdentifier, referenceImageId, clipVisionModel })); }, [dispatch, entityIdentifier, referenceImageId] ); const onChangeImage = useCallback( (imageDTO: ImageDTO | null) => { - dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); + dispatch(rgRefImageImageChanged({ entityIdentifier, referenceImageId, imageDTO })); }, [dispatch, entityIdentifier, referenceImageId] ); @@ -112,9 +112,9 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro () => setRegionalGuidanceReferenceImageDndTarget.getData( { entityIdentifier, referenceImageId }, - ipAdapter.image?.image_name + config.image?.image_name ), - [entityIdentifier, ipAdapter.image?.image_name, referenceImageId] + [entityIdentifier, config.image?.image_name, referenceImageId] ); const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId); @@ -140,9 +140,9 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro - - {ipAdapter.type === 'ip_adapter' && ( - + + {config.type === 'ip_adapter' && ( + )} - {ipAdapter.type === 'ip_adapter' && ( + {config.type === 'ip_adapter' && ( - - - + + + )} - {ipAdapter.type === 'flux_redux' && ( + {config.type === 'flux_redux' && ( )} createSelector(selectCanvasSlice, (canvas) => { const referenceImage = selectRegionalGuidanceReferenceImage(canvas, entityIdentifier, referenceImageId); - return !!referenceImage && referenceImage.ipAdapter.image !== null; + return !!referenceImage && referenceImage.config.image !== null; }); export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Props) => { const entityIdentifier = useEntityIdentifierContext('regional_guidance'); - - const selectIPAdapterHasImage = useMemo( + const selectHasImage = useMemo( () => buildSelectIPAdapterHasImage(entityIdentifier, referenceImageId), [entityIdentifier, referenceImageId] ); - const hasImage = useAppSelector(selectIPAdapterHasImage); + const hasImage = useAppSelector(selectHasImage); if (!hasImage) { return ; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx index e393a97426..e7e3282a64 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx @@ -4,7 +4,7 @@ import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { usePullBboxIntoRegionalGuidanceReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { rgIPAdapterDeleted } from 'features/controlLayers/store/canvasSlice'; +import { rgRefImageDeleted } from 'features/controlLayers/store/canvasSlice'; import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd/dnd'; import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -35,7 +35,7 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); const onDeleteIPAdapter = useCallback(() => { - dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId })); + dispatch(rgRefImageDeleted({ entityIdentifier, referenceImageId })); }, [dispatch, entityIdentifier, referenceImageId]); const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId); diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts index 5bdbfb5555..41d871436e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -10,20 +10,20 @@ import { inpaintMaskNoiseAdded, rasterLayerAdded, rgAdded, - rgIPAdapterAdded, rgNegativePromptChanged, rgPositivePromptChanged, + rgRefImageAdded, } from 'features/controlLayers/store/canvasSlice'; import { selectBase, selectMainModelConfig } from 'features/controlLayers/store/paramsSlice'; -import { referenceImageAdded } from 'features/controlLayers/store/refImagesSlice'; +import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import type { CanvasEntityIdentifier, - CanvasReferenceImageState, CanvasRegionalGuidanceState, ControlLoRAConfig, ControlNetConfig, IPAdapterConfig, + RefImageState, T2IAdapterConfig, } from 'features/controlLayers/store/types'; import { @@ -77,7 +77,7 @@ export const selectDefaultRefImageConfig = createSelector( selectMainModelConfig, selectModelConfigsQuery, selectBase, - (selectedMainModel, query, base): CanvasReferenceImageState['ipAdapter'] => { + (selectedMainModel, query, base): RefImageState['config'] => { if (selectedMainModel?.base === 'chatgpt-4o') { const referenceImage = deepClone(initialChatGPT4oReferenceImage); referenceImage.model = zModelIdentifierField.parse(selectedMainModel); @@ -179,7 +179,7 @@ export const useAddRegionalReferenceImage = () => { const func = useCallback(() => { const overrides: Partial = { referenceImages: [ - { id: getPrefixedId('regional_guidance_reference_image'), ipAdapter: deepClone(defaultIPAdapter) }, + { id: getPrefixedId('regional_guidance_reference_image'), config: deepClone(defaultIPAdapter) }, ], }; dispatch(rgAdded({ isSelected: true, overrides })); @@ -192,8 +192,8 @@ export const useAddGlobalReferenceImage = () => { const dispatch = useAppDispatch(); const defaultRefImage = useAppSelector(selectDefaultRefImageConfig); const func = useCallback(() => { - const overrides = { ipAdapter: deepClone(defaultRefImage) }; - dispatch(referenceImageAdded({ isSelected: true, overrides })); + const overrides = { config: deepClone(defaultRefImage) }; + dispatch(refImageAdded({ isSelected: true, overrides })); }, [defaultRefImage, dispatch]); return func; @@ -203,7 +203,7 @@ export const useAddRegionalGuidanceIPAdapter = (entityIdentifier: CanvasEntityId const dispatch = useAppDispatch(); const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter); const func = useCallback(() => { - dispatch(rgIPAdapterAdded({ entityIdentifier, overrides: { ipAdapter: deepClone(defaultIPAdapter) } })); + dispatch(rgRefImageAdded({ entityIdentifier, overrides: { config: deepClone(defaultIPAdapter) } })); }, [defaultIPAdapter, dispatch, entityIdentifier]); return func; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts index 2e6437cd0b..0e0d2d433d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts @@ -10,7 +10,7 @@ import { entityRasterized, rasterLayerAdded, rgAdded, - rgIPAdapterImageChanged, + rgRefImageImageChanged, } from 'features/controlLayers/store/canvasSlice'; import { selectMainModelConfig, @@ -18,16 +18,16 @@ import { selectPositivePrompt, selectSeed, } from 'features/controlLayers/store/paramsSlice'; -import { referenceImageAdded, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/refImagesSlice'; +import { refImageAdded,refImageImageChanged } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasMetadata } from 'features/controlLayers/store/selectors'; import type { CanvasControlLayerState, CanvasEntityIdentifier, CanvasRasterLayerState, - CanvasReferenceImageState, CanvasRegionalGuidanceState, Rect, - RegionalGuidanceReferenceImageState, + RefImageState, + RegionalGuidanceRefImageState, } from 'features/controlLayers/store/types'; import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; @@ -172,9 +172,9 @@ export const useNewRegionalReferenceImageFromBbox = () => { const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO) => { - const ipAdapter: RegionalGuidanceReferenceImageState = { + const ipAdapter: RegionalGuidanceRefImageState = { id: getPrefixedId('regional_guidance_reference_image'), - ipAdapter: { + config: { ...deepClone(defaultIPAdapter), image: imageDTOToImageWithDims(imageDTO), }, @@ -205,13 +205,13 @@ export const useNewGlobalReferenceImageFromBbox = () => { const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO) => { - const overrides: Partial = { - ipAdapter: { + const overrides: Partial = { + config: { ...deepClone(defaultIPAdapter), image: imageDTOToImageWithDims(imageDTO), }, }; - dispatch(referenceImageAdded({ overrides, isSelected: true })); + dispatch(refImageAdded({ overrides, isSelected: true })); }; return { @@ -311,7 +311,7 @@ export const usePullBboxIntoGlobalReferenceImage = (id: string) => { const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO, _: Rect) => { - dispatch(referenceImageIPAdapterImageChanged({ id, imageDTO })); + dispatch(refImageImageChanged({ id, imageDTO })); }; return { @@ -336,7 +336,7 @@ export const usePullBboxIntoRegionalGuidanceReferenceImage = ( const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO, _: Rect) => { - dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); + dispatch(rgRefImageImageChanged({ entityIdentifier, referenceImageId, imageDTO })); }; return { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 4715679a65..7f967f6a03 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -23,7 +23,7 @@ import type { EntityMovedByPayload, FillStyle, FLUXReduxImageInfluence, - RegionalGuidanceReferenceImageState, + RegionalGuidanceRefImageState, RgbColor, } from 'features/controlLayers/store/types'; import { @@ -38,13 +38,15 @@ import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/par import type { IRect } from 'konva/lib/types'; import { merge } from 'lodash-es'; import type { UndoableOptions } from 'redux-undo'; -import type { - ControlLoRAModelConfig, - ControlNetModelConfig, - FLUXReduxModelConfig, - ImageDTO, - IPAdapterModelConfig, - T2IAdapterModelConfig, +import { + type ControlLoRAModelConfig, + type ControlNetModelConfig, + type FLUXReduxModelConfig, + type ImageDTO, + type IPAdapterModelConfig, + isFluxReduxModelConfig, + isIPAdapterModelConfig, + type T2IAdapterModelConfig, } from 'services/api/types'; import type { @@ -73,7 +75,9 @@ import { getInitialCanvasState, isChatGPT4oAspectRatioID, isFluxKontextAspectRatioID, + isFLUXReduxConfig, isImagenAspectRatioID, + isIPAdapterConfig, } from './types'; import { converters, @@ -670,12 +674,12 @@ export const canvasSlice = createSlice({ } rg.autoNegative = !rg.autoNegative; }, - rgIPAdapterAdded: { + rgRefImageAdded: { reducer: ( state, action: PayloadAction< EntityIdentifierPayload< - { referenceImageId: string; overrides?: Partial }, + { referenceImageId: string; overrides?: Partial }, 'regional_guidance' > > @@ -685,20 +689,17 @@ export const canvasSlice = createSlice({ if (!entity) { return; } - const ipAdapter = { id: referenceImageId, ipAdapter: deepClone(initialIPAdapter) }; - merge(ipAdapter, overrides); - entity.referenceImages.push(ipAdapter); + const config = { id: referenceImageId, config: deepClone(initialIPAdapter) }; + merge(config, overrides); + entity.referenceImages.push(config); }, prepare: ( - payload: EntityIdentifierPayload< - { overrides?: Partial }, - 'regional_guidance' - > + payload: EntityIdentifierPayload<{ overrides?: Partial }, 'regional_guidance'> ) => ({ payload: { ...payload, referenceImageId: getPrefixedId('regional_guidance_ip_adapter') }, }), }, - rgIPAdapterDeleted: ( + rgRefImageDeleted: ( state, action: PayloadAction> ) => { @@ -707,9 +708,9 @@ export const canvasSlice = createSlice({ if (!entity) { return; } - entity.referenceImages = entity.referenceImages.filter((ipAdapter) => ipAdapter.id !== referenceImageId); + entity.referenceImages = entity.referenceImages.filter((config) => config.id !== referenceImageId); }, - rgIPAdapterImageChanged: ( + rgRefImageImageChanged: ( state, action: PayloadAction< EntityIdentifierPayload<{ referenceImageId: string; imageDTO: ImageDTO | null }, 'regional_guidance'> @@ -720,9 +721,9 @@ export const canvasSlice = createSlice({ if (!referenceImage) { return; } - referenceImage.ipAdapter.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; + referenceImage.config.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; }, - rgIPAdapterWeightChanged: ( + rgRefImageIPAdapterWeightChanged: ( state, action: PayloadAction> ) => { @@ -731,13 +732,13 @@ export const canvasSlice = createSlice({ if (!referenceImage) { return; } - if (referenceImage.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(referenceImage.config)) { return; } - referenceImage.ipAdapter.weight = weight; + referenceImage.config.weight = weight; }, - rgIPAdapterBeginEndStepPctChanged: ( + rgRefImageIPAdapterBeginEndStepPctChanged: ( state, action: PayloadAction< EntityIdentifierPayload<{ referenceImageId: string; beginEndStepPct: [number, number] }, 'regional_guidance'> @@ -748,13 +749,12 @@ export const canvasSlice = createSlice({ if (!referenceImage) { return; } - if (referenceImage.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(referenceImage.config)) { return; } - - referenceImage.ipAdapter.beginEndStepPct = beginEndStepPct; + referenceImage.config.beginEndStepPct = beginEndStepPct; }, - rgIPAdapterMethodChanged: ( + rgRefImageIPAdapterMethodChanged: ( state, action: PayloadAction< EntityIdentifierPayload<{ referenceImageId: string; method: IPMethodV2 }, 'regional_guidance'> @@ -765,13 +765,12 @@ export const canvasSlice = createSlice({ if (!referenceImage) { return; } - if (referenceImage.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(referenceImage.config)) { return; } - - referenceImage.ipAdapter.method = method; + referenceImage.config.method = method; }, - rgIPAdapterFLUXReduxImageInfluenceChanged: ( + rgRefImageFLUXReduxImageInfluenceChanged: ( state, action: PayloadAction< EntityIdentifierPayload< @@ -785,13 +784,13 @@ export const canvasSlice = createSlice({ if (!referenceImage) { return; } - if (referenceImage.ipAdapter.type !== 'flux_redux') { + if (!isFLUXReduxConfig(referenceImage.config)) { return; } - referenceImage.ipAdapter.imageInfluence = imageInfluence; + referenceImage.config.imageInfluence = imageInfluence; }, - rgIPAdapterModelChanged: ( + rgRefImageModelChanged: ( state, action: PayloadAction< EntityIdentifierPayload< @@ -808,43 +807,43 @@ export const canvasSlice = createSlice({ if (!referenceImage) { return; } - referenceImage.ipAdapter.model = modelConfig ? zModelIdentifierField.parse(modelConfig) : null; - if (!referenceImage.ipAdapter.model) { + if (!modelConfig) { + referenceImage.config.model = null; return; } - if (referenceImage.ipAdapter.type === 'ip_adapter' && referenceImage.ipAdapter.model.type === 'flux_redux') { + if (isIPAdapterConfig(referenceImage.config) && isFluxReduxModelConfig(modelConfig)) { // Switching from ip_adapter to flux_redux - referenceImage.ipAdapter = { + referenceImage.config = { ...initialFLUXRedux, - image: referenceImage.ipAdapter.image, - model: referenceImage.ipAdapter.model, + image: referenceImage.config.image, + model: zModelIdentifierField.parse(modelConfig), }; return; } - if (referenceImage.ipAdapter.type === 'flux_redux' && referenceImage.ipAdapter.model.type === 'ip_adapter') { + if (isFLUXReduxConfig(referenceImage.config) && isIPAdapterModelConfig(modelConfig)) { // Switching from flux_redux to ip_adapter - referenceImage.ipAdapter = { + referenceImage.config = { ...initialIPAdapter, - image: referenceImage.ipAdapter.image, - model: referenceImage.ipAdapter.model, + image: referenceImage.config.image, + model: zModelIdentifierField.parse(modelConfig), }; return; } - if (referenceImage.ipAdapter.type === 'ip_adapter') { + if (isIPAdapterConfig(referenceImage.config)) { // Ensure that the IP Adapter model is compatible with the CLIP Vision model - if (referenceImage.ipAdapter.model?.base === 'flux') { - referenceImage.ipAdapter.clipVisionModel = 'ViT-L'; - } else if (referenceImage.ipAdapter.clipVisionModel === 'ViT-L') { + if (referenceImage.config.model?.base === 'flux') { + referenceImage.config.clipVisionModel = 'ViT-L'; + } else if (referenceImage.config.clipVisionModel === 'ViT-L') { // Fall back to ViT-H (ViT-G would also work) - referenceImage.ipAdapter.clipVisionModel = 'ViT-H'; + referenceImage.config.clipVisionModel = 'ViT-H'; } } }, - rgIPAdapterCLIPVisionModelChanged: ( + rgRefImageIPAdapterCLIPVisionModelChanged: ( state, action: PayloadAction< EntityIdentifierPayload<{ referenceImageId: string; clipVisionModel: CLIPVisionModelV2 }, 'regional_guidance'> @@ -855,11 +854,10 @@ export const canvasSlice = createSlice({ if (!referenceImage) { return; } - if (referenceImage.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(referenceImage.config)) { return; } - - referenceImage.ipAdapter.clipVisionModel = clipVisionModel; + referenceImage.config.clipVisionModel = clipVisionModel; }, //#region Inpaint mask inpaintMaskAdded: { @@ -1686,15 +1684,15 @@ export const { rgPositivePromptChanged, rgNegativePromptChanged, rgAutoNegativeToggled, - rgIPAdapterAdded, - rgIPAdapterDeleted, - rgIPAdapterImageChanged, - rgIPAdapterWeightChanged, - rgIPAdapterBeginEndStepPctChanged, - rgIPAdapterMethodChanged, - rgIPAdapterModelChanged, - rgIPAdapterCLIPVisionModelChanged, - rgIPAdapterFLUXReduxImageInfluenceChanged, + rgRefImageAdded, + rgRefImageDeleted, + rgRefImageImageChanged, + rgRefImageIPAdapterWeightChanged, + rgRefImageIPAdapterBeginEndStepPctChanged, + rgRefImageIPAdapterMethodChanged, + rgRefImageModelChanged, + rgRefImageIPAdapterCLIPVisionModelChanged, + rgRefImageFLUXReduxImageInfluenceChanged, // Inpaint mask inpaintMaskAdded, inpaintMaskConvertedToRegionalGuidance, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts index 8188342c11..c37a6d9f56 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts @@ -2,7 +2,6 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import type { PersistConfig, RootState } from 'app/store/store'; -import { deepClone } from 'common/util/deepClone'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { canvasMetadataRecalled } from 'features/controlLayers/store/canvasSlice'; import type { FLUXReduxImageInfluence, RefImagesState } from 'features/controlLayers/store/types'; @@ -12,8 +11,8 @@ import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConf import { assert } from 'tsafe'; import type { PartialDeep } from 'type-fest'; -import type { CanvasReferenceImageState, CLIPVisionModelV2, IPMethodV2 } from './types'; -import { getInitialRefImagesState } from './types'; +import type { CLIPVisionModelV2, IPMethodV2, RefImageState } from './types'; +import { getInitialRefImagesState, isFLUXReduxConfig, isIPAdapterConfig } from './types'; import { getReferenceImageState, imageDTOToImageWithDims, @@ -22,84 +21,69 @@ import { initialIPAdapter, } from './util'; -type PayloadWithId = T extends void - ? { id: string } - : { - id: string; - } & T; +type PayloadActionWithId = T extends void + ? PayloadAction<{ id: string }> + : PayloadAction< + { + id: string; + } & T + >; export const refImagesSlice = createSlice({ name: 'refImages', initialState: getInitialRefImagesState(), reducers: { - referenceImageAdded: { - reducer: ( - state, - action: PayloadAction<{ - id: string; - overrides?: PartialDeep; - isSelected?: boolean; - }> - ) => { - const { id, overrides, isSelected } = action.payload; + refImageAdded: { + reducer: (state, action: PayloadActionWithId<{ overrides?: PartialDeep }>) => { + const { id, overrides } = action.payload; const entityState = getReferenceImageState(id, overrides); state.entities.push(entityState); - - if (isSelected) { - state.selectedId = entityState.id; - } }, - prepare: (payload?: { overrides?: PartialDeep; isSelected?: boolean }) => ({ + prepare: (payload?: { overrides?: PartialDeep; isSelected?: boolean }) => ({ payload: { ...payload, id: getPrefixedId('reference_image') }, }), }, - referenceImageRecalled: (state, action: PayloadAction<{ data: CanvasReferenceImageState }>) => { + refImageRecalled: (state, action: PayloadAction<{ data: RefImageState }>) => { const { data } = action.payload; state.entities.push(data); - state.selectedId = data.id; }, - referenceImageIPAdapterImageChanged: ( - state, - action: PayloadAction> - ) => { + refImageImageChanged: (state, action: PayloadActionWithId<{ imageDTO: ImageDTO | null }>) => { const { id, imageDTO } = action.payload; const entity = selectRefImageEntity(state, id); if (!entity) { return; } - entity.ipAdapter.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; + entity.config.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; }, - referenceImageIPAdapterMethodChanged: (state, action: PayloadAction>) => { + refImageIPAdapterMethodChanged: (state, action: PayloadActionWithId<{ method: IPMethodV2 }>) => { const { id, method } = action.payload; const entity = selectRefImageEntity(state, id); if (!entity) { return; } - if (entity.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(entity.config)) { return; } - entity.ipAdapter.method = method; + entity.config.method = method; }, - referenceImageIPAdapterFLUXReduxImageInfluenceChanged: ( + refImageFLUXReduxImageInfluenceChanged: ( state, - action: PayloadAction> + action: PayloadActionWithId<{ imageInfluence: FLUXReduxImageInfluence }> ) => { const { id, imageInfluence } = action.payload; const entity = selectRefImageEntity(state, id); if (!entity) { return; } - if (entity.ipAdapter.type !== 'flux_redux') { + if (!isFLUXReduxConfig(entity.config)) { return; } - entity.ipAdapter.imageInfluence = imageInfluence; + entity.config.imageInfluence = imageInfluence; }, - referenceImageIPAdapterModelChanged: ( + refImageModelChanged: ( state, - action: PayloadAction< - PayloadWithId<{ modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig | null }> - > + action: PayloadActionWithId<{ modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig | null }> ) => { const { id, modelConfig } = action.payload; const entity = selectRefImageEntity(state, id); @@ -107,16 +91,16 @@ export const refImagesSlice = createSlice({ return; } - const oldModel = entity.ipAdapter.model; + const oldModel = entity.config.model; // First set the new model - entity.ipAdapter.model = modelConfig ? zModelIdentifierField.parse(modelConfig) : null; + entity.config.model = modelConfig ? zModelIdentifierField.parse(modelConfig) : null; - if (!entity.ipAdapter.model) { + if (!entity.config.model) { return; } - if (isEqual(oldModel, entity.ipAdapter.model)) { + if (isEqual(oldModel, entity.config.model)) { // Nothing changed, so we don't need to do anything return; } @@ -124,147 +108,85 @@ export const refImagesSlice = createSlice({ // The type of ref image depends on the model. When the user switches the model, we rebuild the ref image. // When we switch the model, we keep the image the same, but change the other parameters. - if (entity.ipAdapter.model.base === 'chatgpt-4o') { + if (entity.config.model.base === 'chatgpt-4o') { // Switching to chatgpt-4o ref image - entity.ipAdapter = { + entity.config = { ...initialChatGPT4oReferenceImage, - image: entity.ipAdapter.image, - model: entity.ipAdapter.model, + image: entity.config.image, + model: entity.config.model, }; return; } - if (entity.ipAdapter.model.type === 'flux_redux') { + if (entity.config.model.type === 'flux_redux') { // Switching to flux_redux - entity.ipAdapter = { + entity.config = { ...initialFLUXRedux, - image: entity.ipAdapter.image, - model: entity.ipAdapter.model, + image: entity.config.image, + model: entity.config.model, }; return; } - if (entity.ipAdapter.model.type === 'ip_adapter') { + if (entity.config.model.type === 'ip_adapter') { // Switching to ip_adapter - entity.ipAdapter = { + entity.config = { ...initialIPAdapter, - image: entity.ipAdapter.image, - model: entity.ipAdapter.model, + image: entity.config.image, + model: entity.config.model, }; // Ensure that the IP Adapter model is compatible with the CLIP Vision model - if (entity.ipAdapter.model?.base === 'flux') { - entity.ipAdapter.clipVisionModel = 'ViT-L'; - } else if (entity.ipAdapter.clipVisionModel === 'ViT-L') { + if (entity.config.model?.base === 'flux') { + entity.config.clipVisionModel = 'ViT-L'; + } else if (entity.config.clipVisionModel === 'ViT-L') { // Fall back to ViT-H (ViT-G would also work) - entity.ipAdapter.clipVisionModel = 'ViT-H'; + entity.config.clipVisionModel = 'ViT-H'; } return; } }, - referenceImageIPAdapterCLIPVisionModelChanged: ( + refImageIPAdapterCLIPVisionModelChanged: ( state, - action: PayloadAction> + action: PayloadActionWithId<{ clipVisionModel: CLIPVisionModelV2 }> ) => { const { id, clipVisionModel } = action.payload; const entity = selectRefImageEntity(state, id); if (!entity) { return; } - if (entity.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(entity.config)) { return; } - entity.ipAdapter.clipVisionModel = clipVisionModel; + entity.config.clipVisionModel = clipVisionModel; }, - referenceImageIPAdapterWeightChanged: (state, action: PayloadAction>) => { + refImageIPAdapterWeightChanged: (state, action: PayloadActionWithId<{ weight: number }>) => { const { id, weight } = action.payload; const entity = selectRefImageEntity(state, id); if (!entity) { return; } - if (entity.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(entity.config)) { return; } - entity.ipAdapter.weight = weight; + entity.config.weight = weight; }, - referenceImageIPAdapterBeginEndStepPctChanged: ( + refImageIPAdapterBeginEndStepPctChanged: ( state, - action: PayloadAction> + action: PayloadActionWithId<{ beginEndStepPct: [number, number] }> ) => { const { id, beginEndStepPct } = action.payload; const entity = selectRefImageEntity(state, id); if (!entity) { return; } - if (entity.ipAdapter.type !== 'ip_adapter') { + if (!isIPAdapterConfig(entity.config)) { return; } - entity.ipAdapter.beginEndStepPct = beginEndStepPct; + entity.config.beginEndStepPct = beginEndStepPct; }, - //#region Shared entity - entitySelected: (state, action: PayloadAction<{ id: string }>) => { + refImageDeleted: (state, action: PayloadActionWithId) => { const { id } = action.payload; - const entity = selectRefImageEntity(state, id); - if (!entity) { - // Cannot select a non-existent entity - return; - } - state.selectedId = id; - }, - entityNameChanged: (state, action: PayloadAction>) => { - const { id, name } = action.payload; - const entity = selectRefImageEntity(state, id); - if (!entity) { - return; - } - entity.name = name; - }, - entityDuplicated: (state, action: PayloadAction) => { - const { id } = action.payload; - const entity = selectRefImageEntity(state, id); - if (!entity) { - return; - } - - const newEntity = deepClone(entity); - if (newEntity.name) { - newEntity.name = `${newEntity.name} (Copy)`; - } - newEntity.id = getPrefixedId('reference_image'); - state.entities.push(newEntity); - - state.selectedId = newEntity.id; - }, - entityIsEnabledToggled: (state, action: PayloadAction) => { - const { id } = action.payload; - const entity = selectRefImageEntity(state, id); - if (!entity) { - return; - } - entity.isEnabled = !entity.isEnabled; - }, - entityIsLockedToggled: (state, action: PayloadAction) => { - const { id } = action.payload; - const entity = selectRefImageEntity(state, id); - if (!entity) { - return; - } - entity.isLocked = !entity.isLocked; - }, - entityDeleted: (state, action: PayloadAction) => { - const { id } = action.payload; - - let selectedId: string | null = null; - const entities = state.entities; - const index = entities.findIndex((entity) => entity.id === id); - const nextIndex = entities.length > 1 ? (index + 1) % entities.length : -1; - if (nextIndex !== -1) { - const nextEntity = entities[nextIndex]; - if (nextEntity) { - selectedId = nextEntity.id; - } - } state.entities = state.entities.filter((rg) => rg.id !== id); - state.selectedId = selectedId; }, refImagesReset: () => getInitialRefImagesState(), }, @@ -277,15 +199,14 @@ export const refImagesSlice = createSlice({ }); export const { - referenceImageAdded, - // referenceImageRecalled, - referenceImageIPAdapterImageChanged, - referenceImageIPAdapterMethodChanged, - referenceImageIPAdapterModelChanged, - referenceImageIPAdapterCLIPVisionModelChanged, - referenceImageIPAdapterWeightChanged, - referenceImageIPAdapterBeginEndStepPctChanged, - referenceImageIPAdapterFLUXReduxImageInfluenceChanged, + refImageAdded, + refImageImageChanged, + refImageIPAdapterMethodChanged, + refImageModelChanged, + refImageIPAdapterCLIPVisionModelChanged, + refImageIPAdapterWeightChanged, + refImageIPAdapterBeginEndStepPctChanged, + refImageFLUXReduxImageInfluenceChanged, } = refImagesSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -303,20 +224,13 @@ export const refImagesPersistConfig: PersistConfig = { export const selectRefImagesSlice = (state: RootState) => state.refImages; export const selectReferenceImageEntities = createSelector(selectRefImagesSlice, (state) => state.entities); -export const selectActiveReferenceImageEntities = createSelector(selectReferenceImageEntities, (entities) => - entities.filter((e) => e.isEnabled) -); export const selectRefImageEntityIds = createMemoizedSelector(selectReferenceImageEntities, (entities) => entities.map((e) => e.id) ); export const selectRefImageEntity = (state: RefImagesState, id: string) => state.entities.find((entity) => entity.id === id) ?? null; -export function selectRefImageEntityOrThrow( - state: RefImagesState, - id: string, - caller: string -): CanvasReferenceImageState { +export function selectRefImageEntityOrThrow(state: RefImagesState, id: string, caller: string): RefImageState { const entity = selectRefImageEntity(state, id); assert(entity, `Entity with id ${id} not found in ${caller}`); return entity; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 8914e5805c..dacaff07ca 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -300,25 +300,25 @@ const zCanvasEntityBase = z.object({ isLocked: z.boolean(), }); -const zCanvasReferenceImageState = zCanvasEntityBase.extend({ - type: z.literal('reference_image'), +const zRefImageState = z.object({ + id: zId, // This should be named `referenceImage` but we need to keep it as `ipAdapter` for backwards compatibility - ipAdapter: z.discriminatedUnion('type', [ + config: z.discriminatedUnion('type', [ zIPAdapterConfig, zFLUXReduxConfig, zChatGPT4oReferenceImageConfig, zFluxKontextReferenceImageConfig, ]), }); -export type CanvasReferenceImageState = z.infer; +export type RefImageState = z.infer; -export const isIPAdapterConfig = (config: CanvasReferenceImageState['ipAdapter']): config is IPAdapterConfig => +export const isIPAdapterConfig = (config: RefImageState['config']): config is IPAdapterConfig => config.type === 'ip_adapter'; -export const isFLUXReduxConfig = (config: CanvasReferenceImageState['ipAdapter']): config is FLUXReduxConfig => +export const isFLUXReduxConfig = (config: RefImageState['config']): config is FLUXReduxConfig => config.type === 'flux_redux'; export const isChatGPT4oReferenceImageConfig = ( - config: CanvasReferenceImageState['ipAdapter'] + config: RefImageState['config'] ): config is ChatGPT4oReferenceImageConfig => config.type === 'chatgpt_4o_reference_image'; export const isFluxKontextReferenceImageConfig = ( config: CanvasReferenceImageState['ipAdapter'] @@ -329,11 +329,11 @@ export type FillStyle = z.infer; export const isFillStyle = (v: unknown): v is FillStyle => zFillStyle.safeParse(v).success; const zFill = z.object({ style: zFillStyle, color: zRgbColor }); -const zRegionalGuidanceReferenceImageState = z.object({ +const zRegionalGuidanceRefImageState = z.object({ id: zId, - ipAdapter: z.discriminatedUnion('type', [zIPAdapterConfig, zFLUXReduxConfig]), + config: z.discriminatedUnion('type', [zIPAdapterConfig, zFLUXReduxConfig]), }); -export type RegionalGuidanceReferenceImageState = z.infer; +export type RegionalGuidanceRefImageState = z.infer; const zCanvasRegionalGuidanceState = zCanvasEntityBase.extend({ type: z.literal('regional_guidance'), @@ -343,7 +343,7 @@ const zCanvasRegionalGuidanceState = zCanvasEntityBase.extend({ fill: zFill, positivePrompt: zParameterPositivePrompt.nullable(), negativePrompt: zParameterNegativePrompt.nullable(), - referenceImages: z.array(zRegionalGuidanceReferenceImageState), + referenceImages: z.array(zRegionalGuidanceRefImageState), autoNegative: z.boolean(), }); export type CanvasRegionalGuidanceState = z.infer; @@ -578,8 +578,7 @@ const zCanvasState = z.object({ export type CanvasState = z.infer; const zRefImagesState = z.object({ - selectedId: zId.nullable().default(null), - entities: z.array(zCanvasReferenceImageState).default(() => []), + entities: z.array(zRefImageState).default(() => []), }); export type RefImagesState = z.infer; const INITIAL_REF_IMAGES_STATE = zRefImagesState.parse({}); @@ -596,7 +595,7 @@ export const zCanvasMetadata = z.object({ rasterLayers: z.array(zCanvasRasterLayerState), controlLayers: z.array(zCanvasControlLayerState), regionalGuidance: z.array(zCanvasRegionalGuidanceState), - referenceImages: z.array(zCanvasReferenceImageState), + referenceImages: z.array(zRefImageState), }); export type CanvasMetadata = z.infer; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts index 8cebbe4846..bcc6f7943c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -5,7 +5,6 @@ import type { CanvasImageState, CanvasInpaintMaskState, CanvasRasterLayerState, - CanvasReferenceImageState, CanvasRegionalGuidanceState, ChatGPT4oReferenceImageConfig, ControlLoRAConfig, @@ -15,6 +14,7 @@ import type { FLUXReduxConfig, ImageWithDims, IPAdapterConfig, + RefImageState, RgbColor, T2IAdapterConfig, } from 'features/controlLayers/store/types'; @@ -126,17 +126,10 @@ export const initialControlLoRA: ControlLoRAConfig = { weight: 0.75, }; -export const getReferenceImageState = ( - id: string, - overrides?: PartialDeep -): CanvasReferenceImageState => { - const entityState: CanvasReferenceImageState = { +export const getReferenceImageState = (id: string, overrides?: PartialDeep): RefImageState => { + const entityState: RefImageState = { id, - type: 'reference_image', - name: null, - isLocked: false, - isEnabled: true, - ipAdapter: deepClone(initialIPAdapter), + config: deepClone(initialIPAdapter), }; merge(entityState, overrides); return entityState; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts index 425dab23ff..534c9a337a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts @@ -2,8 +2,8 @@ import type { CanvasControlLayerState, CanvasInpaintMaskState, CanvasRasterLayerState, - CanvasReferenceImageState, CanvasRegionalGuidanceState, + RefImageState, } from 'features/controlLayers/store/types'; import type { MainModelConfig } from 'services/api/types'; @@ -58,16 +58,16 @@ export const getRegionalGuidanceWarnings = ( } } - entity.referenceImages.forEach(({ ipAdapter }) => { - if (!ipAdapter.model) { + entity.referenceImages.forEach(({ config }) => { + if (!config.model) { // No model selected warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED); - } else if (ipAdapter.model.base !== model.base) { + } else if (config.model.base !== model.base) { // Supported model architecture but doesn't match warnings.push(WARNINGS.IP_ADAPTER_INCOMPATIBLE_BASE_MODEL); } - if (!ipAdapter.image) { + if (!config.image) { // No image selected warnings.push(WARNINGS.IP_ADAPTER_NO_IMAGE_SELECTED); } @@ -78,7 +78,7 @@ export const getRegionalGuidanceWarnings = ( }; export const getGlobalReferenceImageWarnings = ( - entity: CanvasReferenceImageState, + entity: RefImageState, model: MainModelConfig | null | undefined ): WarningTKey[] => { const warnings: WarningTKey[] = []; @@ -90,17 +90,17 @@ export const getGlobalReferenceImageWarnings = ( return warnings; } - const { ipAdapter } = entity; + const { config } = entity; - if (!ipAdapter.model) { + if (!config.model) { // No model selected warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED); - } else if (ipAdapter.model.base !== model.base) { + } else if (config.model.base !== model.base) { // Supported model architecture but doesn't match warnings.push(WARNINGS.IP_ADAPTER_INCOMPATIBLE_BASE_MODEL); } - if (!entity.ipAdapter.image) { + if (!entity.config.image) { // No image selected warnings.push(WARNINGS.IP_ADAPTER_NO_IMAGE_SELECTED); } diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts index 6f4fd19571..8dec87f5e7 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts @@ -3,7 +3,7 @@ import { getStore, useAppStore } from 'app/store/nanostores/store'; import type { AppDispatch, AppGetState, RootState } from 'app/store/store'; import { entityDeleted } from 'features/controlLayers/store/canvasSlice'; import { - referenceImageIPAdapterImageChanged, + refImageImageChanged, selectReferenceImageEntities, selectRefImagesSlice, } from 'features/controlLayers/store/refImagesSlice'; @@ -228,8 +228,8 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { selectReferenceImageEntities(state).forEach((entity) => { - if (entity.ipAdapter.image?.image_name === imageDTO.image_name) { - dispatch(referenceImageIPAdapterImageChanged({ id: entity.id, imageDTO: null })); + if (entity.config.image?.image_name === imageDTO.image_name) { + dispatch(refImageImageChanged({ id: entity.id, imageDTO: null })); } }); }; @@ -276,7 +276,7 @@ export const getImageUsage = ( const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name; - const isReferenceImage = refImages.entities.some(({ ipAdapter }) => ipAdapter.image?.image_name === image_name); + const isReferenceImage = refImages.entities.some(({ config }) => config.image?.image_name === image_name); const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) => objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) @@ -291,7 +291,7 @@ export const getImageUsage = ( ); const isRegionalGuidanceImage = canvas.regionalGuidance.entities.some(({ referenceImages }) => - referenceImages.some(({ ipAdapter }) => ipAdapter.image?.image_name === image_name) + referenceImages.some(({ config }) => config.image?.image_name === image_name) ); const imageUsage: ImageUsage = { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx index fa20556c5d..7b3971ba39 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx @@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/nanostores/store'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { referenceImageAdded } from 'features/controlLayers/store/refImagesSlice'; +import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; @@ -77,7 +77,7 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { const onClickNewGlobalReferenceImageFromImage = useCallback(() => { const { dispatch } = store; - dispatch(referenceImageAdded({ overrides: { ipAdapter: { image: imageDTOToImageWithDims(imageDTO) } } })); + dispatch(refImageAdded({ overrides: { config: { image: imageDTOToImageWithDims(imageDTO) } } })); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); imageViewer.close(); diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index 81fe6e9c44..babbdd35a2 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -11,10 +11,10 @@ import { inpaintMaskAdded, rasterLayerAdded, rgAdded, - rgIPAdapterImageChanged, + rgRefImageImageChanged, } from 'features/controlLayers/store/canvasSlice'; import { canvasSessionTypeChanged } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { referenceImageAdded, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/refImagesSlice'; +import { refImageAdded, refImageImageChanged } from 'features/controlLayers/store/refImagesSlice'; import { selectBboxModelBase, selectBboxRect } from 'features/controlLayers/store/selectors'; import type { CanvasControlLayerState, @@ -41,7 +41,7 @@ import { assert } from 'tsafe'; export const setGlobalReferenceImage = (arg: { imageDTO: ImageDTO; id: string; dispatch: AppDispatch }) => { const { imageDTO, id, dispatch } = arg; - dispatch(referenceImageIPAdapterImageChanged({ id, imageDTO })); + dispatch(refImageImageChanged({ id, imageDTO })); }; export const setRegionalGuidanceReferenceImage = (arg: { @@ -51,7 +51,7 @@ export const setRegionalGuidanceReferenceImage = (arg: { dispatch: AppDispatch; }) => { const { imageDTO, entityIdentifier, referenceImageId, dispatch } = arg; - dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); + dispatch(rgRefImageImageChanged({ entityIdentifier, referenceImageId, imageDTO })); }; export const setUpscaleInitialImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => { @@ -112,9 +112,9 @@ export const createNewCanvasEntityFromImage = (arg: { break; } case 'regional_guidance_with_reference_image': { - const ipAdapter = deepClone(selectDefaultIPAdapter(getState())); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }]; + const config = deepClone(selectDefaultIPAdapter(getState())); + config.image = imageDTOToImageWithDims(imageDTO); + const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), config }]; dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true })); break; } @@ -242,10 +242,10 @@ export const newCanvasFromImage = async (arg: { break; } case 'reference_image': { - const ipAdapter = deepClone(selectDefaultRefImageConfig(getState())); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); + const config = deepClone(selectDefaultRefImageConfig(getState())); + config.image = imageDTOToImageWithDims(imageDTO); dispatch(canvasSessionTypeChanged({ type: 'advanced' })); - dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true })); + dispatch(refImageAdded({ overrides: { config }, isSelected: true })); if (withInpaintMask) { dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true })); } @@ -253,9 +253,9 @@ export const newCanvasFromImage = async (arg: { break; } case 'regional_guidance_with_reference_image': { - const ipAdapter = deepClone(selectDefaultIPAdapter(getState())); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }]; + const config = deepClone(selectDefaultIPAdapter(getState())); + config.image = imageDTOToImageWithDims(imageDTO); + const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), config }]; dispatch(canvasSessionTypeChanged({ type: 'advanced' })); dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true })); if (withInpaintMask) { diff --git a/invokeai/frontend/web/src/features/metadata/util/parsers.ts b/invokeai/frontend/web/src/features/metadata/util/parsers.ts index 16ede40209..4504c05667 100644 --- a/invokeai/frontend/web/src/features/metadata/util/parsers.ts +++ b/invokeai/frontend/web/src/features/metadata/util/parsers.ts @@ -5,9 +5,9 @@ import type { CanvasInpaintMaskState, CanvasMetadata, CanvasRasterLayerState, - CanvasReferenceImageState, CanvasRegionalGuidanceState, LoRA, + RefImageState, } from 'features/controlLayers/store/types'; import { zCanvasMetadata, zCanvasRasterLayerState } from 'features/controlLayers/store/types'; import { @@ -417,7 +417,7 @@ const parseAllIPAdapters: MetadataParseFunc = async ( const parseLayer: MetadataParseFunc< | CanvasRasterLayerState | CanvasControlLayerState - | CanvasReferenceImageState + | RefImageState | CanvasRegionalGuidanceState | CanvasInpaintMaskState > = (metadataItem) => zCanvasRasterLayerState.parseAsync(metadataItem); @@ -431,7 +431,7 @@ const parseLayers: MetadataParseFunc< ( | CanvasRasterLayerState | CanvasControlLayerState - | CanvasReferenceImageState + | RefImageState | CanvasRegionalGuidanceState | CanvasInpaintMaskState )[] @@ -444,7 +444,7 @@ const parseLayers: MetadataParseFunc< const layers: ( | CanvasRasterLayerState | CanvasControlLayerState - | CanvasReferenceImageState + | RefImageState | CanvasRegionalGuidanceState | CanvasInpaintMaskState )[] = []; @@ -493,7 +493,7 @@ const parseLayers: MetadataParseFunc< ipAdaptersRaw.map(async (cn) => await parseIPAdapterToIPAdapterLayer(cn)) ); const ipAdaptersAsLayers = ipAdaptersParseResults - .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') .map((result) => result.value); layers.push(...ipAdaptersAsLayers); } catch { @@ -598,7 +598,7 @@ const parseT2IAdapterToControlAdapterLayer: MetadataParseFunc = async (metadataItem) => { +const parseIPAdapterToIPAdapterLayer: MetadataParseFunc = async (metadataItem) => { const ip_adapter_model = await getProperty(metadataItem, 'ip_adapter_model'); const key = await getModelKey(ip_adapter_model, 'ip_adapter'); const ipAdapterModel = await fetchModelConfigWithTypeGuard(key, isIPAdapterModelConfig); @@ -630,13 +630,9 @@ const parseIPAdapterToIPAdapterLayer: MetadataParseFunc; model: MainModelConfig; @@ -22,19 +18,18 @@ type AddFLUXReduxArg = { export const addFLUXReduxes = ({ entities, g, collector, model }: AddFLUXReduxArg): AddFLUXReduxResult => { const validFLUXReduxes = entities - .filter((entity) => entity.isEnabled) - .filter((entity) => isFLUXReduxConfig(entity.ipAdapter)) + .filter((entity) => isFLUXReduxConfig(entity.config)) .filter((entity) => getGlobalReferenceImageWarnings(entity, model).length === 0); const result: AddFLUXReduxResult = { addedFLUXReduxes: 0, }; - for (const { id, ipAdapter } of validFLUXReduxes) { - assert(isFLUXReduxConfig(ipAdapter), 'This should have been filtered out'); + for (const { id, config } of validFLUXReduxes) { + assert(isFLUXReduxConfig(config), 'This should have been filtered out'); result.addedFLUXReduxes++; - addFLUXRedux(id, ipAdapter, g, collector); + addFLUXRedux(id, config, g, collector); } return result; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts index f063afbb29..49e94aa03f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts @@ -1,8 +1,4 @@ -import { - type CanvasReferenceImageState, - type IPAdapterConfig, - isIPAdapterConfig, -} from 'features/controlLayers/store/types'; +import { type IPAdapterConfig, isIPAdapterConfig,type RefImageState } from 'features/controlLayers/store/types'; import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import type { Invocation, MainModelConfig } from 'services/api/types'; @@ -13,7 +9,7 @@ type AddIPAdaptersResult = { }; type AddIPAdaptersArg = { - entities: CanvasReferenceImageState[]; + entities: RefImageState[]; g: Graph; collector: Invocation<'collect'>; model: MainModelConfig; @@ -21,19 +17,18 @@ type AddIPAdaptersArg = { export const addIPAdapters = ({ entities, g, collector, model }: AddIPAdaptersArg): AddIPAdaptersResult => { const validIPAdapters = entities - .filter((entity) => entity.isEnabled) - .filter((entity) => isIPAdapterConfig(entity.ipAdapter)) + .filter((entity) => isIPAdapterConfig(entity.config)) .filter((entity) => getGlobalReferenceImageWarnings(entity, model).length === 0); const result: AddIPAdaptersResult = { addedIPAdapters: 0, }; - for (const { id, ipAdapter } of validIPAdapters) { - assert(isIPAdapterConfig(ipAdapter), 'This should have been filtered out'); + for (const { id, config } of validIPAdapters) { + assert(isIPAdapterConfig(config), 'This should have been filtered out'); result.addedIPAdapters++; - addIPAdapter(id, ipAdapter, g, collector); + addIPAdapter(id, config, g, collector); } return result; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts index d36591bdae..7ab6c7e7fa 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts @@ -3,7 +3,12 @@ import { deepClone } from 'common/util/deepClone'; import { withResultAsync } from 'common/util/result'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import type { CanvasRegionalGuidanceState, Rect } from 'features/controlLayers/store/types'; +import { + type CanvasRegionalGuidanceState, + isFLUXReduxConfig, + isIPAdapterConfig, + type Rect, +} from 'features/controlLayers/store/types'; import { getRegionalGuidanceWarnings } from 'features/controlLayers/store/validators'; import { IMAGE_INFLUENCE_TO_SETTINGS } from 'features/nodes/util/graph/generation/addFLUXRedux'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; @@ -273,12 +278,12 @@ export const addRegions = async ({ } } - for (const { id, ipAdapter } of region.referenceImages) { - if (ipAdapter.type === 'ip_adapter') { + for (const { id, config } of region.referenceImages) { + if (isIPAdapterConfig(config)) { assert(!isFLUX, 'Regional IP adapters are not supported for FLUX.'); result.addedIPAdapters++; - const { weight, model, clipVisionModel, method, beginEndStepPct, image } = ipAdapter; + const { weight, model, clipVisionModel, method, beginEndStepPct, image } = config; assert(model, 'IP Adapter model is required'); assert(image, 'IP Adapter image is required'); @@ -299,11 +304,11 @@ export const addRegions = async ({ // Connect the mask to the conditioning g.addEdge(maskToTensor, 'mask', ipAdapterNode, 'mask'); g.addEdge(ipAdapterNode, 'ip_adapter', ipAdapterCollect, 'item'); - } else if (ipAdapter.type === 'flux_redux') { + } else if (isFLUXReduxConfig(config)) { assert(isFLUX, 'Regional FLUX Redux requires FLUX.'); assert(fluxReduxCollect !== null, 'FLUX Redux collector is required.'); result.addedFLUXReduxes++; - const { model: fluxReduxModel, image } = ipAdapter; + const { model: fluxReduxModel, image } = config; assert(fluxReduxModel, 'FLUX Redux model is required'); assert(image, 'FLUX Redux image is required'); @@ -314,7 +319,7 @@ export const addRegions = async ({ image: { image_name: image.image_name, }, - ...IMAGE_INFLUENCE_TO_SETTINGS[ipAdapter.imageInfluence ?? 'highest'], + ...IMAGE_INFLUENCE_TO_SETTINGS[config.imageInfluence ?? 'highest'], }); // Connect the mask to the conditioning diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index 09a24b9669..2f389d059a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -44,8 +44,7 @@ export const buildChatGPT4oGraph = async ( assert(isChatGPT4oAspectRatioID(bbox.aspectRatio.id), 'ChatGPT 4o does not support this aspect ratio'); const validRefImages = refImages.entities - .filter((entity) => entity.isEnabled) - .filter((entity) => isChatGPT4oReferenceImageConfig(entity.ipAdapter)) + .filter((entity) => isChatGPT4oReferenceImageConfig(entity.config)) .filter((entity) => getGlobalReferenceImageWarnings(entity, model).length === 0) .toReversed(); // sends them in order they are displayed in the list @@ -54,9 +53,9 @@ export const buildChatGPT4oGraph = async ( if (validRefImages.length > 0) { reference_images = []; for (const entity of validRefImages) { - assert(entity.ipAdapter.image, 'Image is required for reference image'); + assert(entity.config.image, 'Image is required for reference image'); reference_images.push({ - image_name: entity.ipAdapter.image.image_name, + image_name: entity.config.image.image_name, }); } } diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index 2355feed84..1d8e8a5444 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -523,27 +523,23 @@ const getReasonsWhyCannotEnqueueCanvasTab = (arg: { }); // Flux Kontext only supports 1x Reference Image at a time. - const referenceImageCount = refImages.entities.filter((entity) => entity.isEnabled).length; + const referenceImageCount = refImages.entities.length; if (model?.base === 'flux-kontext' && referenceImageCount > 1) { reasons.push({ content: i18n.t('parameters.invoke.fluxKontextMultipleReferenceImages') }); } - refImages.entities - .filter((entity) => entity.isEnabled) - .forEach((entity, i) => { - const layerLiteral = i18n.t('controlLayers.layer_one'); - const layerNumber = i + 1; - const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]); - const prefix = `${layerLiteral} #${layerNumber} (${layerType})`; + refImages.entities.forEach((entity, i) => { + const layerNumber = i + 1; + const refImageLiteral = i18n.t(LAYER_TYPE_TO_TKEY['reference_image']); + const prefix = `${refImageLiteral} #${layerNumber}`; + const problems = getGlobalReferenceImageWarnings(entity, model); - const problems = getGlobalReferenceImageWarnings(entity, model); - - if (problems.length) { - const content = upperFirst(problems.map((p) => i18n.t(p)).join(', ')); - reasons.push({ prefix, content }); - } - }); + if (problems.length) { + const content = upperFirst(problems.map((p) => i18n.t(p)).join(', ')); + reasons.push({ prefix, content }); + } + }); canvas.regionalGuidance.entities .filter((entity) => entity.isEnabled) From 48e2e7e4a11e33ef58d0d80529646fdde46127fe Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:08:03 +1000 Subject: [PATCH 088/210] refactor(ui): ref images (WIP) --- .../components/CanvasAddEntityButtons.tsx | 4 +- ...nvasContextMenuSelectedEntityMenuItems.tsx | 2 +- .../EntityListGlobalActionBarAddLayerMenu.tsx | 4 +- .../components/IPAdapter/IPAdapter.tsx | 29 --- .../{IPAdapter => RefImage}/IPAdapterList.tsx | 4 +- .../IPAdapterMenuItemPullBbox.tsx | 0 .../IPAdapterMenuItems.tsx | 2 +- .../IPAdapterMethod.tsx | 0 .../IPAdapterSettings.tsx | 52 +++--- .../RefImage.tsx} | 11 +- .../RefImageImage.tsx} | 4 +- .../RefImageModel.tsx} | 4 +- .../RefImageNoImageState.tsx} | 4 +- ...onalGuidanceAddPromptsIPAdapterButtons.tsx | 12 +- .../RegionalGuidanceIPAdapterSettings.tsx | 10 +- ...uidanceMenuItemsAddPromptsAndIPAdapter.tsx | 12 +- .../components/common/CanvasEntityHeader.tsx | 2 +- ...Model.tsx => IPAdapterCLIPVisionModel.tsx} | 4 +- .../controlLayers/hooks/addLayerHooks.ts | 171 +++++++++--------- .../controlLayers/hooks/saveCanvasHooks.ts | 23 +-- invokeai/frontend/web/src/features/dnd/dnd.ts | 6 +- .../ImageMenuItemNewLayerFromImageSubMenu.tsx | 22 --- .../ImageMenuItemUseAsRefImage.tsx | 39 ++++ .../SingleSelectionMenuItems.tsx | 2 + .../web/src/features/imageActions/actions.ts | 19 +- .../components/Core/ParamPositivePrompt.tsx | 2 +- .../src/services/api/hooks/modelsByType.ts | 20 +- .../services/events/onInvocationComplete.tsx | 4 +- .../services/events/onModelInstallError.tsx | 4 +- 29 files changed, 235 insertions(+), 237 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => RefImage}/IPAdapterList.tsx (88%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => RefImage}/IPAdapterMenuItemPullBbox.tsx (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => RefImage}/IPAdapterMenuItems.tsx (94%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => RefImage}/IPAdapterMethod.tsx (100%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter => RefImage}/IPAdapterSettings.tsx (77%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter/IPAdapterPreview.tsx => RefImage/RefImage.tsx} (89%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter/IPAdapterImagePreview.tsx => RefImage/RefImageImage.tsx} (96%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter/GlobalReferenceImageModel.tsx => RefImage/RefImageModel.tsx} (93%) rename invokeai/frontend/web/src/features/controlLayers/components/{IPAdapter/IPAdapterSettingsEmptyState.tsx => RefImage/RefImageNoImageState.tsx} (95%) rename invokeai/frontend/web/src/features/controlLayers/components/common/{CLIPVisionModel.tsx => IPAdapterCLIPVisionModel.tsx} (92%) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx index 6027ed14d2..d3475a385c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx @@ -5,7 +5,7 @@ import { useAddInpaintMask, useAddRasterLayer, useAddRegionalGuidance, - useAddRegionalReferenceImage, + useAddNewRegionalGuidanceWithARefImage, } from 'features/controlLayers/hooks/addLayerHooks'; import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled'; import { memo } from 'react'; @@ -18,7 +18,7 @@ export const CanvasAddEntityButtons = memo(() => { const addRegionalGuidance = useAddRegionalGuidance(); const addRasterLayer = useAddRasterLayer(); const addControlLayer = useAddControlLayer(); - const addRegionalReferenceImage = useAddRegionalReferenceImage(); + const addRegionalReferenceImage = useAddNewRegionalGuidanceWithARefImage(); const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance'); const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer'); const isInpaintLayerEnabled = useIsEntityTypeEnabled('inpaint_mask'); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx index 015e1d9238..8d150e0bb7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx @@ -2,7 +2,7 @@ import { MenuGroup } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { ControlLayerMenuItems } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItems'; import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItems'; -import { IPAdapterMenuItems } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItems'; +import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems'; import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems'; import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems'; import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx index 9ffdc58bf6..04994f200e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx @@ -4,7 +4,7 @@ import { useAddInpaintMask, useAddRasterLayer, useAddRegionalGuidance, - useAddRegionalReferenceImage, + useAddNewRegionalGuidanceWithARefImage, } from 'features/controlLayers/hooks/addLayerHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled'; @@ -17,7 +17,7 @@ export const EntityListGlobalActionBarAddLayerMenu = memo(() => { const isBusy = useCanvasIsBusy(); const addInpaintMask = useAddInpaintMask(); const addRegionalGuidance = useAddRegionalGuidance(); - const addRegionalReferenceImage = useAddRegionalReferenceImage(); + const addRegionalReferenceImage = useAddNewRegionalGuidanceWithARefImage(); const addRasterLayer = useAddRasterLayer(); const addControlLayer = useAddControlLayer(); const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance'); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx deleted file mode 100644 index a31841a716..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Spacer } from '@invoke-ai/ui-library'; -import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; -import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; -import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; -import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; -import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings'; -import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; -import { memo } from 'react'; - -type Props = { - id: string; -}; - -export const IPAdapter = memo(({ id }: Props) => { - return ( - - - - - - - - - - - ); -}); - -IPAdapter.displayName = 'IPAdapter'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx similarity index 88% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx index c803bae13c..6cda069930 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx @@ -2,7 +2,7 @@ import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { RefImagePreview } from 'features/controlLayers/components/IPAdapter/IPAdapterPreview'; +import { RefImage } from 'features/controlLayers/components/RefImage/RefImage'; import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; import { memo } from 'react'; @@ -27,7 +27,7 @@ export const RefImageList = memo((props: FlexProps) => { {ids.map((id) => ( - + ))} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterMenuItemPullBbox.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterMenuItemPullBbox.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterMenuItems.tsx similarity index 94% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItems.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterMenuItems.tsx index f174bdf004..94d30ad5d2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterMenuItems.tsx @@ -3,7 +3,7 @@ import { IconMenuItemGroup } from 'common/components/IconMenuItem'; import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete'; import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate'; -import { IPAdapterMenuItemPullBbox } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox'; +import { IPAdapterMenuItemPullBbox } from 'features/controlLayers/components/RefImage/IPAdapterMenuItemPullBbox'; import { memo } from 'react'; export const IPAdapterMenuItems = memo(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMethod.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterMethod.tsx similarity index 100% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMethod.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterMethod.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterSettings.tsx similarity index 77% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterSettings.tsx index 1e884209b4..8afacb0d2d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterSettings.tsx @@ -2,12 +2,12 @@ import { Flex } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; -import { CLIPVisionModel } from 'features/controlLayers/components/common/CLIPVisionModel'; import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence'; +import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel'; import { Weight } from 'features/controlLayers/components/common/Weight'; -import { GlobalReferenceImageModel } from 'features/controlLayers/components/IPAdapter/GlobalReferenceImageModel'; -import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod'; -import { IPAdapterSettingsEmptyState } from 'features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState'; +import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod'; +import { RefImageModel } from 'features/controlLayers/components/RefImage/RefImageModel'; +import { RefImageNoImageState } from 'features/controlLayers/components/RefImage/RefImageNoImageState'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { @@ -22,10 +22,12 @@ import { selectRefImageEntityOrThrow, selectRefImagesSlice, } from 'features/controlLayers/store/refImagesSlice'; -import type { - CLIPVisionModelV2, - FLUXReduxImageInfluence as FLUXReduxImageInfluenceType, - IPMethodV2, +import { + type CLIPVisionModelV2, + type FLUXReduxImageInfluence as FLUXReduxImageInfluenceType, + type IPMethodV2, + isFLUXReduxConfig, + isIPAdapterConfig, } from 'features/controlLayers/store/types'; import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; @@ -33,7 +35,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types'; -import { IPAdapterImagePreview } from './IPAdapterImagePreview'; +import { RefImageImage } from './RefImageImage'; const buildSelectConfig = (id: string) => createSelector( @@ -45,8 +47,8 @@ const IPAdapterSettingsContent = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const id = useRefImageIdContext(); - const selectIPAdapter = useMemo(() => buildSelectConfig(id), [id]); - const ipAdapter = useAppSelector(selectIPAdapter); + const selectConfig = useMemo(() => buildSelectConfig(id), [id]); + const config = useAppSelector(selectConfig); const onChangeBeginEndStepPct = useCallback( (beginEndStepPct: [number, number]) => { @@ -98,8 +100,8 @@ const IPAdapterSettingsContent = memo(() => { ); const dndTargetData = useMemo( - () => setGlobalReferenceImageDndTarget.getData({ id }, ipAdapter.image?.image_name), - [id, ipAdapter.image?.image_name] + () => setGlobalReferenceImageDndTarget.getData({ id }, config.image?.image_name), + [id, config.image?.image_name] ); // const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id); // const isBusy = useCanvasIsBusy(); @@ -109,9 +111,9 @@ const IPAdapterSettingsContent = memo(() => { return ( - - {ipAdapter.type === 'ip_adapter' && ( - + + {isIPAdapterConfig(config) && ( + )} {/* { /> */} - {ipAdapter.type === 'ip_adapter' && ( + {isIPAdapterConfig(config) && ( - {!isFLUX && } - - + {!isFLUX && } + + )} - {ipAdapter.type === 'flux_redux' && ( + {isFLUXReduxConfig(config) && ( )} - { const hasImage = useAppSelector(selectIPAdapterHasImage); if (!hasImage) { - return ; + return ; } return ; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx similarity index 89% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx index 01b85fb5f1..1212fbe3fa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterPreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx @@ -14,7 +14,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { useDisclosure } from 'common/hooks/useBoolean'; import { useFilterableOutsideClick } from 'common/hooks/useFilterableOutsideClick'; -import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings'; +import { IPAdapterSettings } from 'features/controlLayers/components/RefImage/IPAdapterSettings'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { selectRefImageEntityOrThrow, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import type { ImageWithDims } from 'features/controlLayers/store/types'; @@ -34,15 +34,12 @@ const sx: SystemStyleObject = { transitionDuration: '0.2s', }; -export const RefImagePreview = memo(() => { +export const RefImage = memo(() => { const id = useRefImageIdContext(); const ref = useRef(null); const disclosure = useDisclosure(false); const selectEntity = useMemo( - () => - createSelector(selectRefImagesSlice, (refImages) => - selectRefImageEntityOrThrow(refImages, id, 'RefImagePreview') - ), + () => createSelector(selectRefImagesSlice, (refImages) => selectRefImageEntityOrThrow(refImages, id, 'RefImage')), [id] ); const entity = useAppSelector(selectEntity); @@ -66,7 +63,7 @@ export const RefImagePreview = memo(() => { ); }); -RefImagePreview.displayName = 'RefImagePreview'; +RefImage.displayName = 'RefImage'; const Thumbnail = memo(({ image }: { image: ImageWithDims | null }) => { const { data: imageDTO } = useGetImageDTOQuery(image?.image_name ?? skipToken); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx similarity index 96% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx index 57d1a3f3f4..979b40f5e1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx @@ -21,7 +21,7 @@ type Props; }; -export const IPAdapterImagePreview = memo( +export const RefImageImage = memo( ({ image, onChangeImage, @@ -77,4 +77,4 @@ export const IPAdapterImagePreview = memo( } ); -IPAdapterImagePreview.displayName = 'IPAdapterImagePreview'; +RefImageImage.displayName = 'RefImageImage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/GlobalReferenceImageModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageModel.tsx similarity index 93% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/GlobalReferenceImageModel.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageModel.tsx index a0b6b4ad4e..8e6e0749aa 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/GlobalReferenceImageModel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageModel.tsx @@ -12,7 +12,7 @@ type Props = { onChangeModel: (modelConfig: IPAdapterModelConfig | FLUXReduxModelConfig | ApiModelConfig) => void; }; -export const GlobalReferenceImageModel = memo(({ modelKey, onChangeModel }: Props) => { +export const RefImageModel = memo(({ modelKey, onChangeModel }: Props) => { const { t } = useTranslation(); const currentBaseModel = useAppSelector(selectBase); const [modelConfigs, { isLoading }] = useGlobalReferenceImageModels(); @@ -60,4 +60,4 @@ export const GlobalReferenceImageModel = memo(({ modelKey, onChangeModel }: Prop ); }); -GlobalReferenceImageModel.displayName = 'GlobalReferenceImageModel'; +RefImageModel.displayName = 'RefImageModel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx similarity index 95% rename from invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx index 3bf6744d99..3a82fd16f9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettingsEmptyState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx @@ -13,7 +13,7 @@ import { memo, useCallback, useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import type { ImageDTO } from 'services/api/types'; -export const IPAdapterSettingsEmptyState = memo(() => { +export const RefImageNoImageState = memo(() => { const { t } = useTranslation(); const id = useRefImageIdContext(); const dispatch = useAppDispatch(); @@ -66,4 +66,4 @@ export const IPAdapterSettingsEmptyState = memo(() => { ); }); -IPAdapterSettingsEmptyState.displayName = 'IPAdapterSettingsEmptyState'; +RefImageNoImageState.displayName = 'RefImageNoImageState'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx index 059135b507..cb88c281f7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx @@ -3,9 +3,9 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { buildSelectValidRegionalGuidanceActions, - useAddRegionalGuidanceIPAdapter, - useAddRegionalGuidanceNegativePrompt, - useAddRegionalGuidancePositivePrompt, + useAddRefImageToExistingRegionalGuidance, + useAddNegativePromptToExistingRegionalGuidance, + useAddPositivePromptToExistingRegionalGuidance, } from 'features/controlLayers/hooks/addLayerHooks'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,9 +14,9 @@ import { PiPlusBold } from 'react-icons/pi'; export const RegionalGuidanceAddPromptsIPAdapterButtons = () => { const entityIdentifier = useEntityIdentifierContext('regional_guidance'); const { t } = useTranslation(); - const addRegionalGuidanceIPAdapter = useAddRegionalGuidanceIPAdapter(entityIdentifier); - const addRegionalGuidancePositivePrompt = useAddRegionalGuidancePositivePrompt(entityIdentifier); - const addRegionalGuidanceNegativePrompt = useAddRegionalGuidanceNegativePrompt(entityIdentifier); + const addRegionalGuidanceIPAdapter = useAddRefImageToExistingRegionalGuidance(entityIdentifier); + const addRegionalGuidancePositivePrompt = useAddPositivePromptToExistingRegionalGuidance(entityIdentifier); + const addRegionalGuidanceNegativePrompt = useAddNegativePromptToExistingRegionalGuidance(entityIdentifier); const selectValidActions = useMemo( () => buildSelectValidRegionalGuidanceActions(entityIdentifier), diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 4cc434c439..70445b484b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -2,11 +2,11 @@ import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; -import { CLIPVisionModel } from 'features/controlLayers/components/common/CLIPVisionModel'; +import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel'; import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence'; import { Weight } from 'features/controlLayers/components/common/Weight'; -import { IPAdapterImagePreview } from 'features/controlLayers/components/IPAdapter/IPAdapterImagePreview'; -import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod'; +import { RefImageImage } from 'features/controlLayers/components/RefImage/RefImageImage'; +import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod'; import { RegionalGuidanceIPAdapterSettingsEmptyState } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState'; import { RegionalReferenceImageModel } from 'features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -142,7 +142,7 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro {config.type === 'ip_adapter' && ( - + )} )} - { const entityIdentifier = useEntityIdentifierContext('regional_guidance'); const { t } = useTranslation(); const isBusy = useCanvasIsBusy(); - const addRegionalGuidanceIPAdapter = useAddRegionalGuidanceIPAdapter(entityIdentifier); - const addRegionalGuidancePositivePrompt = useAddRegionalGuidancePositivePrompt(entityIdentifier); - const addRegionalGuidanceNegativePrompt = useAddRegionalGuidanceNegativePrompt(entityIdentifier); + const addRegionalGuidanceIPAdapter = useAddRefImageToExistingRegionalGuidance(entityIdentifier); + const addRegionalGuidancePositivePrompt = useAddPositivePromptToExistingRegionalGuidance(entityIdentifier); + const addRegionalGuidanceNegativePrompt = useAddNegativePromptToExistingRegionalGuidance(entityIdentifier); const selectValidActions = useMemo( () => buildSelectValidRegionalGuidanceActions(entityIdentifier), [entityIdentifier] diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx index 89a924ad6c..8c8e07cdc9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx @@ -2,7 +2,7 @@ import type { FlexProps } from '@invoke-ai/ui-library'; import { ContextMenu, Flex, MenuList } from '@invoke-ai/ui-library'; import { ControlLayerMenuItems } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItems'; import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItems'; -import { IPAdapterMenuItems } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItems'; +import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems'; import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems'; import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CLIPVisionModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/IPAdapterCLIPVisionModel.tsx similarity index 92% rename from invokeai/frontend/web/src/features/controlLayers/components/common/CLIPVisionModel.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/common/IPAdapterCLIPVisionModel.tsx index 6023fd579f..dd7917d39a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CLIPVisionModel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/IPAdapterCLIPVisionModel.tsx @@ -22,7 +22,7 @@ type Props = { onChange: (clipVisionModel: CLIPVisionModelV2) => void; }; -export const CLIPVisionModel = memo(({ model, onChange }: Props) => { +export const IPAdapterCLIPVisionModel = memo(({ model, onChange }: Props) => { const { t } = useTranslation(); const _onChangeCLIPVisionModel = useCallback( @@ -58,4 +58,4 @@ export const CLIPVisionModel = memo(({ model, onChange }: Props) => { ); }); -CLIPVisionModel.displayName = 'CLIPVisionModel'; +IPAdapterCLIPVisionModel.displayName = 'IPAdapterCLIPVisionModel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts index 41d871436e..f8860e210c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -1,6 +1,8 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppStore } from 'app/store/nanostores/store'; +import type { AppGetState } from 'app/store/store'; +import { useAppDispatch } from 'app/store/storeHooks'; import { deepClone } from 'common/util/deepClone'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { @@ -20,10 +22,11 @@ import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/se import type { CanvasEntityIdentifier, CanvasRegionalGuidanceState, + ChatGPT4oReferenceImageConfig, ControlLoRAConfig, ControlNetConfig, + FluxKontextReferenceImageConfig, IPAdapterConfig, - RefImageState, T2IAdapterConfig, } from 'features/controlLayers/store/types'; import { @@ -36,13 +39,9 @@ import { import { zModelIdentifierField } from 'features/nodes/types/common'; import { useCallback } from 'react'; import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; -import type { - ControlLoRAModelConfig, - ControlNetModelConfig, - IPAdapterModelConfig, - T2IAdapterModelConfig, -} from 'services/api/types'; -import { isControlLayerModelConfig, isIPAdapterModelConfig } from 'services/api/types'; +import { selectIPAdapterModels } from 'services/api/hooks/modelsByType'; +import type { ControlLoRAModelConfig, ControlNetModelConfig, T2IAdapterModelConfig } from 'services/api/types'; +import { isControlLayerModelConfig } from 'services/api/types'; /** * Selects the default control adapter configuration based on the model configurations and the base. @@ -73,67 +72,69 @@ export const selectDefaultControlAdapter = createSelector( } ); -export const selectDefaultRefImageConfig = createSelector( - selectMainModelConfig, - selectModelConfigsQuery, - selectBase, - (selectedMainModel, query, base): RefImageState['config'] => { - if (selectedMainModel?.base === 'chatgpt-4o') { - const referenceImage = deepClone(initialChatGPT4oReferenceImage); - referenceImage.model = zModelIdentifierField.parse(selectedMainModel); - return referenceImage; - } +export const getDefaultRefImageConfig = ( + getState: AppGetState +): IPAdapterConfig | ChatGPT4oReferenceImageConfig | FluxKontextReferenceImageConfig => { + const state = getState(); - if (selectedMainModel?.base === 'flux-kontext') { - const referenceImage = deepClone(initialFluxKontextReferenceImage); - referenceImage.model = zModelIdentifierField.parse(selectedMainModel); - return referenceImage; - } + const mainModelConfig = selectMainModelConfig(state); + const ipAdapterModelConfigs = selectIPAdapterModels(state); - const { data } = query; - let model: IPAdapterModelConfig | null = null; - if (data) { - const modelConfigs = modelConfigsAdapterSelectors.selectAll(data).filter(isIPAdapterModelConfig); - const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true)); - model = compatibleModels[0] ?? modelConfigs[0] ?? null; - } - const ipAdapter = deepClone(initialIPAdapter); - if (model) { - ipAdapter.model = zModelIdentifierField.parse(model); - if (model.base === 'flux') { - ipAdapter.clipVisionModel = 'ViT-L'; - } - } - return ipAdapter; + const base = mainModelConfig?.base; + + // For ChatGPT-4o, the ref image model is the model itself. + if (base === 'chatgpt-4o') { + const config = deepClone(initialChatGPT4oReferenceImage); + config.model = zModelIdentifierField.parse(mainModelConfig); + return config; } -); -/** - * Selects the default IP adapter configuration based on the model configurations and the base. - * - * Be sure to clone the output of this selector before modifying it! - */ -export const selectDefaultIPAdapter = createSelector( - selectModelConfigsQuery, - selectBase, - (query, base): IPAdapterConfig => { - const { data } = query; - let model: IPAdapterModelConfig | null = null; - if (data) { - const modelConfigs = modelConfigsAdapterSelectors.selectAll(data).filter(isIPAdapterModelConfig); - const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true)); - model = compatibleModels[0] ?? modelConfigs[0] ?? null; - } - const ipAdapter = deepClone(initialIPAdapter); - if (model) { - ipAdapter.model = zModelIdentifierField.parse(model); - if (model.base === 'flux') { - ipAdapter.clipVisionModel = 'ViT-L'; - } - } - return ipAdapter; + if (base === 'flux-kontext') { + const config = deepClone(initialFluxKontextReferenceImage); + config.model = zModelIdentifierField.parse(mainModelConfig); + return config; } -); + + // Otherwise, find the first compatible IP Adapter model. + const modelConfig = ipAdapterModelConfigs.find((m) => m.base === base); + + // Clone the initial IP Adapter config and set the model if available. + const config = deepClone(initialIPAdapter); + + if (modelConfig) { + config.model = zModelIdentifierField.parse(modelConfig); + // FLUX models use a different vision model. + if (modelConfig.base === 'flux') { + config.clipVisionModel = 'ViT-L'; + } + } + return config; +}; + +export const getDefaultRegionalGuidanceRefImageConfig = (getState: AppGetState): IPAdapterConfig => { + // Regional guidance ref images do not support ChatGPT-4o, so we always return the IP Adapter config. + const state = getState(); + + const mainModelConfig = selectMainModelConfig(state); + const ipAdapterModelConfigs = selectIPAdapterModels(state); + + const base = mainModelConfig?.base; + + // Find the first compatible IP Adapter model. + const modelConfig = ipAdapterModelConfigs.find((m) => m.base === base); + + // Clone the initial IP Adapter config and set the model if available. + const config = deepClone(initialIPAdapter); + + if (modelConfig) { + config.model = zModelIdentifierField.parse(modelConfig); + // FLUX models use a different vision model. + if (modelConfig.base === 'flux') { + config.clipVisionModel = 'ViT-L'; + } + } + return config; +}; export const useAddControlLayer = () => { const dispatch = useAppDispatch(); @@ -172,44 +173,46 @@ export const useAddRegionalGuidance = () => { return func; }; -export const useAddRegionalReferenceImage = () => { - const dispatch = useAppDispatch(); - const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter); +export const useAddNewRegionalGuidanceWithARefImage = () => { + const { dispatch, getState } = useAppStore(); const func = useCallback(() => { + const config = getDefaultRegionalGuidanceRefImageConfig(getState); const overrides: Partial = { - referenceImages: [ - { id: getPrefixedId('regional_guidance_reference_image'), config: deepClone(defaultIPAdapter) }, - ], + referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), config }], }; dispatch(rgAdded({ isSelected: true, overrides })); - }, [defaultIPAdapter, dispatch]); + }, [dispatch, getState]); return func; }; export const useAddGlobalReferenceImage = () => { - const dispatch = useAppDispatch(); - const defaultRefImage = useAppSelector(selectDefaultRefImageConfig); + const { dispatch, getState } = useAppStore(); const func = useCallback(() => { - const overrides = { config: deepClone(defaultRefImage) }; + const config = getDefaultRefImageConfig(getState); + const overrides = { config }; dispatch(refImageAdded({ isSelected: true, overrides })); - }, [defaultRefImage, dispatch]); + }, [dispatch, getState]); return func; }; -export const useAddRegionalGuidanceIPAdapter = (entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>) => { - const dispatch = useAppDispatch(); - const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter); +export const useAddRefImageToExistingRegionalGuidance = ( + entityIdentifier: CanvasEntityIdentifier<'regional_guidance'> +) => { + const { dispatch, getState } = useAppStore(); const func = useCallback(() => { - dispatch(rgRefImageAdded({ entityIdentifier, overrides: { config: deepClone(defaultIPAdapter) } })); - }, [defaultIPAdapter, dispatch, entityIdentifier]); + const config = getDefaultRegionalGuidanceRefImageConfig(getState); + dispatch(rgRefImageAdded({ entityIdentifier, overrides: { config } })); + }, [dispatch, entityIdentifier, getState]); return func; }; -export const useAddRegionalGuidancePositivePrompt = (entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>) => { +export const useAddPositivePromptToExistingRegionalGuidance = ( + entityIdentifier: CanvasEntityIdentifier<'regional_guidance'> +) => { const dispatch = useAppDispatch(); const func = useCallback(() => { dispatch(rgPositivePromptChanged({ entityIdentifier, prompt: '' })); @@ -218,7 +221,9 @@ export const useAddRegionalGuidancePositivePrompt = (entityIdentifier: CanvasEnt return func; }; -export const useAddRegionalGuidanceNegativePrompt = (entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>) => { +export const useAddNegativePromptToExistingRegionalGuidance = ( + entityIdentifier: CanvasEntityIdentifier<'regional_guidance'> +) => { const dispatch = useAppDispatch(); const runc = useCallback(() => { dispatch(rgNegativePromptChanged({ entityIdentifier, prompt: '' })); diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts index 0e0d2d433d..395768585f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts @@ -1,9 +1,12 @@ import { logger } from 'app/logging/logger'; -import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { useAppDispatch, useAppStore } from 'app/store/storeHooks'; import { deepClone } from 'common/util/deepClone'; import { withResultAsync } from 'common/util/result'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { selectDefaultIPAdapter, selectDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; +import { + getDefaultRefImageConfig, + getDefaultRegionalGuidanceRefImageConfig, +} from 'features/controlLayers/hooks/addLayerHooks'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { controlLayerAdded, @@ -18,7 +21,7 @@ import { selectPositivePrompt, selectSeed, } from 'features/controlLayers/store/paramsSlice'; -import { refImageAdded,refImageImageChanged } from 'features/controlLayers/store/refImagesSlice'; +import { refImageAdded, refImageImageChanged } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasMetadata } from 'features/controlLayers/store/selectors'; import type { CanvasControlLayerState, @@ -167,15 +170,14 @@ export const useSaveBboxToGallery = () => { export const useNewRegionalReferenceImageFromBbox = () => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter); + const { dispatch, getState } = useAppStore(); const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO) => { const ipAdapter: RegionalGuidanceRefImageState = { id: getPrefixedId('regional_guidance_reference_image'), config: { - ...deepClone(defaultIPAdapter), + ...getDefaultRegionalGuidanceRefImageConfig(getState), image: imageDTOToImageWithDims(imageDTO), }, }; @@ -193,21 +195,20 @@ export const useNewRegionalReferenceImageFromBbox = () => { toastOk: t('controlLayers.newRegionalReferenceImageOk'), toastError: t('controlLayers.newRegionalReferenceImageError'), }; - }, [defaultIPAdapter, dispatch, t]); + }, [dispatch, getState, t]); const func = useSaveCanvas(arg); return func; }; export const useNewGlobalReferenceImageFromBbox = () => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const defaultIPAdapter = useAppSelector(selectDefaultRefImageConfig); + const { dispatch, getState } = useAppStore(); const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO) => { const overrides: Partial = { config: { - ...deepClone(defaultIPAdapter), + ...getDefaultRefImageConfig(getState), image: imageDTOToImageWithDims(imageDTO), }, }; @@ -221,7 +222,7 @@ export const useNewGlobalReferenceImageFromBbox = () => { toastOk: t('controlLayers.newGlobalReferenceImageOk'), toastError: t('controlLayers.newGlobalReferenceImageError'), }; - }, [defaultIPAdapter, dispatch, t]); + }, [dispatch, getState, t]); const func = useSaveCanvas(arg); return func; }; diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index 925a118694..bb401727d3 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -1,5 +1,5 @@ import { logger } from 'app/logging/logger'; -import type { AppDispatch, RootState } from 'app/store/store'; +import type { AppDispatch, AppGetState } from 'app/store/store'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types'; import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; @@ -114,13 +114,13 @@ type DndTarget = { sourceData: RecordUnknown; targetData: TargetData; dispatch: AppDispatch; - getState: () => RootState; + getState: AppGetState; }) => boolean; handler: (arg: { sourceData: SourceData; targetData: TargetData; dispatch: AppDispatch; - getState: () => RootState; + getState: AppGetState; }) => void; }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx index 7b3971ba39..4fc9934f07 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx @@ -3,8 +3,6 @@ import { useAppStore } from 'app/store/nanostores/store'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { sentImageToCanvas } from 'features/gallery/store/actions'; @@ -75,19 +73,6 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { }); }, [imageDTO, imageViewer, store, t]); - const onClickNewGlobalReferenceImageFromImage = useCallback(() => { - const { dispatch } = store; - dispatch(refImageAdded({ overrides: { config: { image: imageDTOToImageWithDims(imageDTO) } } })); - dispatch(sentImageToCanvas()); - dispatch(setActiveTab('canvas')); - imageViewer.close(); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); - }, [imageDTO, imageViewer, store, t]); - const onClickNewRegionalReferenceImageFromImage = useCallback(() => { const { dispatch, getState } = store; createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState }); @@ -127,13 +112,6 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { > {t('controlLayers.referenceImageRegional')} - } - onClickCapture={onClickNewGlobalReferenceImageFromImage} - isDisabled={isBusy} - > - {t('controlLayers.referenceImageGlobal')} -
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage.tsx new file mode 100644 index 0000000000..344f81ef7f --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage.tsx @@ -0,0 +1,39 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; +import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; +import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { toast } from 'features/toast/toast'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiImageBold } from 'react-icons/pi'; + +export const ImageMenuItemUseAsRefImage = memo(() => { + const { t } = useTranslation(); + const store = useAppStore(); + const imageDTO = useImageDTOContext(); + const imageViewer = useImageViewer(); + + const onClickNewGlobalReferenceImageFromImage = useCallback(() => { + const { dispatch, getState } = store; + const config = getDefaultRefImageConfig(getState); + config.image = imageDTOToImageWithDims(imageDTO); + dispatch(refImageAdded({ overrides: { config } })); + imageViewer.close(); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); + }, [imageDTO, imageViewer, store, t]); + + return ( + } onClickCapture={onClickNewGlobalReferenceImageFromImage}> + Use as Reference Image + + ); +}); + +ImageMenuItemUseAsRefImage.displayName = 'ImageMenuItemUseAsRefImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx index bac272d6c1..368d265735 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -13,6 +13,7 @@ import { ImageMenuItemOpenInViewer } from 'features/gallery/components/ImageCont import { ImageMenuItemSelectForCompare } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare'; import { ImageMenuItemSendToUpscale } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale'; import { ImageMenuItemStarUnstar } from 'features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar'; +import { ImageMenuItemUseAsRefImage } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage'; import { ImageDTOContextProvider } from 'features/gallery/contexts/ImageDTOContext'; import { memo } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -37,6 +38,7 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) = + diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index babbdd35a2..52db1a7c09 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -1,6 +1,9 @@ -import type { AppDispatch, RootState } from 'app/store/store'; +import type { AppDispatch, AppGetState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { selectDefaultIPAdapter, selectDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; +import { + getDefaultRefImageConfig, + getDefaultRegionalGuidanceRefImageConfig, +} from 'features/controlLayers/hooks/addLayerHooks'; import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityTransformer'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { @@ -77,7 +80,7 @@ export const createNewCanvasEntityFromImage = (arg: { imageDTO: ImageDTO; type: CanvasEntityType | 'regional_guidance_with_reference_image'; dispatch: AppDispatch; - getState: () => RootState; + getState: AppGetState; overrides?: Partial>; }) => { const { type, imageDTO, dispatch, getState, overrides: _overrides } = arg; @@ -112,7 +115,7 @@ export const createNewCanvasEntityFromImage = (arg: { break; } case 'regional_guidance_with_reference_image': { - const config = deepClone(selectDefaultIPAdapter(getState())); + const config = getDefaultRegionalGuidanceRefImageConfig(getState); config.image = imageDTOToImageWithDims(imageDTO); const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), config }]; dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true })); @@ -138,7 +141,7 @@ export const newCanvasFromImage = async (arg: { withResize?: boolean; withInpaintMask?: boolean; dispatch: AppDispatch; - getState: () => RootState; + getState: AppGetState; }) => { const { type, imageDTO, withResize = false, withInpaintMask = false, dispatch, getState } = arg; const state = getState(); @@ -242,7 +245,7 @@ export const newCanvasFromImage = async (arg: { break; } case 'reference_image': { - const config = deepClone(selectDefaultRefImageConfig(getState())); + const config = deepClone(getDefaultRefImageConfig(getState)); config.image = imageDTOToImageWithDims(imageDTO); dispatch(canvasSessionTypeChanged({ type: 'advanced' })); dispatch(refImageAdded({ overrides: { config }, isSelected: true })); @@ -253,7 +256,7 @@ export const newCanvasFromImage = async (arg: { break; } case 'regional_guidance_with_reference_image': { - const config = deepClone(selectDefaultIPAdapter(getState())); + const config = getDefaultRegionalGuidanceRefImageConfig(getState); config.image = imageDTOToImageWithDims(imageDTO); const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), config }]; dispatch(canvasSessionTypeChanged({ type: 'advanced' })); @@ -273,7 +276,7 @@ export const replaceCanvasEntityObjectsWithImage = (arg: { imageDTO: ImageDTO; entityIdentifier: CanvasEntityIdentifier; dispatch: AppDispatch; - getState: () => RootState; + getState: AppGetState; }) => { const { imageDTO, entityIdentifier, dispatch, getState } = arg; const imageObject = imageDTOToImageObject(imageDTO); diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx index f6f71927c8..aa65205470 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx @@ -1,7 +1,7 @@ import { Box, Textarea } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize'; -import { RefImageList } from 'features/controlLayers/components/IPAdapter/IPAdapterList'; +import { RefImageList } from 'features/controlLayers/components/RefImage/IPAdapterList'; import { positivePromptChanged, selectBase, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice'; import { ShowDynamicPromptsPreviewButton } from 'features/dynamicPrompts/components/ShowDynamicPromptsPreviewButton'; import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel'; diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts index fc2c782003..553d441005 100644 --- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts +++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts @@ -101,15 +101,15 @@ export const useImagen4Models = buildModelsHook(isImagen4ModelConfig); export const useChatGPT4oModels = buildModelsHook(isChatGPT4oModelConfig); export const useFluxKontextModels = buildModelsHook(isFluxKontextModelConfig); -// const buildModelsSelector = -// (typeGuard: (config: AnyModelConfig) => config is T): Selector => -// (state) => { -// const result = selectModelConfigsQuery(state); -// if (!result.data) { -// return EMPTY_ARRAY; -// } -// return modelConfigsAdapterSelectors.selectAll(result.data).filter(typeGuard); -// }; +const buildModelsSelector = + (typeGuard: (config: AnyModelConfig) => config is T): Selector => + (state) => { + const result = selectModelConfigsQuery(state); + if (!result.data) { + return EMPTY_ARRAY; + } + return modelConfigsAdapterSelectors.selectAll(result.data).filter(typeGuard); + }; // export const selectSDMainModels = buildModelsSelector(isNonRefinerNonFluxMainModelConfig); // export const selectMainModels = buildModelsSelector(isNonRefinerMainModelConfig); // export const selectNonSDXLMainModels = buildModelsSelector(isNonSDXLMainModelConfig); @@ -123,7 +123,7 @@ export const useFluxKontextModels = buildModelsHook(isFluxKontextModelConfig); // export const selectT5EncoderModels = buildModelsSelector(isT5EncoderModelConfig); // export const selectClipEmbedModels = buildModelsSelector(isClipEmbedModelConfig); // export const selectSpandrelImageToImageModels = buildModelsSelector(isSpandrelImageToImageModelConfig); -// export const selectIPAdapterModels = buildModelsSelector(isIPAdapterModelConfig); +export const selectIPAdapterModels = buildModelsSelector(isIPAdapterModelConfig); // export const selectEmbeddingModels = buildModelsSelector(isTIModelConfig); // export const selectVAEModels = buildModelsSelector(isVAEModelConfig); // export const selectFluxVAEModels = buildModelsSelector(isFluxVAEModelConfig); diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 8e299202e9..c40d3fb5a0 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -1,5 +1,5 @@ import { logger } from 'app/logging/logger'; -import type { AppDispatch, RootState } from 'app/store/store'; +import type { AppDispatch, AppGetState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; @@ -20,7 +20,7 @@ const log = logger('events'); const nodeTypeDenylist = ['load_image', 'image']; -export const buildOnInvocationComplete = (getState: () => RootState, dispatch: AppDispatch) => { +export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDispatch) => { const addImagesToGallery = async (data: S['InvocationCompleteEvent']) => { if (nodeTypeDenylist.includes(data.invocation.type)) { log.trace(`Skipping denylisted node type (${data.invocation.type})`); diff --git a/invokeai/frontend/web/src/services/events/onModelInstallError.tsx b/invokeai/frontend/web/src/services/events/onModelInstallError.tsx index 3ae11de353..36c6762323 100644 --- a/invokeai/frontend/web/src/services/events/onModelInstallError.tsx +++ b/invokeai/frontend/web/src/services/events/onModelInstallError.tsx @@ -1,7 +1,7 @@ import { Button, ExternalLink, Spinner, Text } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { logger } from 'app/logging/logger'; -import type { AppDispatch, RootState } from 'app/store/store'; +import type { AppDispatch, AppGetState } from 'app/store/store'; import { useAppDispatch } from 'app/store/storeHooks'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; @@ -41,7 +41,7 @@ const getHFTokenStatus = async (dispatch: AppDispatch): Promise RootState, dispatch: AppDispatch) => { +export const buildOnModelInstallError = (getState: AppGetState, dispatch: AppDispatch) => { return async (data: S['ModelInstallErrorEvent']) => { log.error({ data }, 'Model install error'); From 8d1ab0a2e565708dcbab8fee1ed09e946ccea888 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:19:02 +1000 Subject: [PATCH 089/210] refactor(ui): ref images (WIP) --- invokeai/frontend/web/public/locales/en.json | 3 +- .../web/src/common/hooks/useBoolean.ts | 2 +- .../common/hooks/useFilterableOutsideClick.ts | 20 ++- .../components/RefImage/IPAdapterList.tsx | 37 ----- .../components/RefImage/RefImage.tsx | 137 +++++++++++++----- .../components/RefImage/RefImageHeader.tsx | 41 ++++++ .../components/RefImage/RefImageList.tsx | 77 ++++++++++ .../RefImage/RefImageNoImageState.tsx | 18 +-- .../RefImageNoImageStateWithCanvasOptions.tsx | 69 +++++++++ ...apterSettings.tsx => RefImageSettings.tsx} | 57 +++++--- .../components/RefImage/useRefImageEntity.ts | 16 ++ ...nalGuidanceIPAdapterSettingsEmptyState.tsx | 2 +- .../common/PullBboxIntoRefImageIconButton.tsx | 28 ++++ .../controlLayers/hooks/addLayerHooks.ts | 2 +- .../controlLayers/store/refImagesSlice.ts | 3 +- .../web/src/features/imageActions/actions.ts | 2 +- .../components/Core/ParamPositivePrompt.tsx | 2 - .../parameters/components/Prompts/Prompts.tsx | 2 + .../features/ui/components/TabMountGate.tsx | 3 + 19 files changed, 394 insertions(+), 127 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageStateWithCanvasOptions.tsx rename invokeai/frontend/web/src/features/controlLayers/components/RefImage/{IPAdapterSettings.tsx => RefImageSettings.tsx} (80%) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageEntity.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/common/PullBboxIntoRefImageIconButton.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0375f0f456..ce14674926 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2017,7 +2017,8 @@ "resetGenerationSettings": "Reset Generation Settings", "replaceCurrent": "Replace Current", "controlLayerEmptyState": "Upload an image, drag an image from the gallery onto this layer, pull the bounding box into this layer, or draw on the canvas to get started.", - "referenceImageEmptyState": "Upload an image, drag an image from the gallery onto this layer, or pull the bounding box into this layer to get started.", + "referenceImageEmptyStateWithCanvasOptions": "Upload an image, drag an image from the gallery onto this Reference Image or pull the bounding box into this Reference Image to get started.", + "referenceImageEmptyState": "Upload an image or drag an image from the gallery onto this Reference Image to get started.", "uploadOrDragAnImage": "Drag an image from the gallery or upload an image.", "imageNoise": "Image Noise", "denoiseLimit": "Denoise Limit", diff --git a/invokeai/frontend/web/src/common/hooks/useBoolean.ts b/invokeai/frontend/web/src/common/hooks/useBoolean.ts index ec68457ecd..9b571d31bc 100644 --- a/invokeai/frontend/web/src/common/hooks/useBoolean.ts +++ b/invokeai/frontend/web/src/common/hooks/useBoolean.ts @@ -73,7 +73,7 @@ export const useBoolean = (initialValue: boolean): UseBoolean => { }; }; -type UseDisclosure = { +export type UseDisclosure = { isOpen: boolean; open: () => void; close: () => void; diff --git a/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts b/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts index 6b45cb8554..4317fc01d9 100644 --- a/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts +++ b/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts @@ -22,6 +22,8 @@ import { useCallback, useEffect, useRef } from 'react'; +type FilterFunction = (el: HTMLElement | SVGElement) => boolean; + export function useCallbackRef any>( callback: T | undefined, deps: React.DependencyList = [] @@ -54,10 +56,17 @@ export interface UseOutsideClickProps { * * If omitted, a default filter function that ignores clicks in Chakra UI portals and react-select components is used. */ - filter?: (el: HTMLElement) => boolean; + filter?: FilterFunction; } -const DEFAULT_FILTER = (el: HTMLElement) => el.className.includes('chakra-portal') || el.id.includes('react-select'); +export const DEFAULT_FILTER: FilterFunction = (el) => { + if (el instanceof SVGElement) { + // SVGElement's type appears to be incorrect. Its className is not a string, which causes `includes` to fail. + // Let's assume that SVG elements with a class name are not part of the portal and should not be filtered. + return false; + } + return el.className.includes('chakra-portal') || el.id.includes('react-select'); +}; /** * Example, used in components like Dialogs and Popovers, so they can close @@ -119,11 +128,7 @@ export function useFilterableOutsideClick(props: UseOutsideClickProps) { }, [handler, ref, savedHandler, state, enabled, filter]); } -function isValidEvent( - event: Event, - ref: React.RefObject, - filter?: (el: HTMLElement) => boolean -): boolean { +function isValidEvent(event: Event, ref: React.RefObject, filter?: FilterFunction): boolean { const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement; if (target) { @@ -137,6 +142,7 @@ function isValidEvent( return false; } + // This is the main logic change from the original hook. if (filter) { // Check if the click is inside an element matching the filter. // This is used for portal-awareness or other general exclusion cases. diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx deleted file mode 100644 index 6cda069930..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable i18next/no-literal-string */ -import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; -import { Flex } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { RefImage } from 'features/controlLayers/components/RefImage/RefImage'; -import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; -import { selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; -import { memo } from 'react'; - -const sx: SystemStyleObject = { - opacity: 0.3, - _hover: { - opacity: 1, - }, - transitionProperty: 'opacity', - transitionDuration: '0.2s', -}; - -export const RefImageList = memo((props: FlexProps) => { - const ids = useAppSelector(selectRefImageEntityIds); - - if (ids.length === 0) { - return null; - } - - return ( - - {ids.map((id) => ( - - - - ))} - - ); -}); - -RefImageList.displayName = 'RefImageList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx index 1212fbe3fa..ca21d6a84a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx @@ -1,62 +1,77 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import type { + SystemStyleObject} from '@invoke-ai/ui-library'; import { + Divider, Flex, + IconButton, Image, Popover, PopoverAnchor, PopoverArrow, PopoverBody, PopoverContent, - Portal, + Portal } from '@invoke-ai/ui-library'; -import { createSelector } from '@reduxjs/toolkit'; import { skipToken } from '@reduxjs/toolkit/query'; -import { useAppSelector } from 'app/store/storeHooks'; +import { POPPER_MODIFIERS } from 'common/components/InformationalPopover/constants'; +import type { UseDisclosure } from 'common/hooks/useBoolean'; import { useDisclosure } from 'common/hooks/useBoolean'; -import { useFilterableOutsideClick } from 'common/hooks/useFilterableOutsideClick'; -import { IPAdapterSettings } from 'features/controlLayers/components/RefImage/IPAdapterSettings'; +import { DEFAULT_FILTER, useFilterableOutsideClick } from 'common/hooks/useFilterableOutsideClick'; +import { RefImageHeader } from 'features/controlLayers/components/RefImage/RefImageHeader'; +import { RefImageSettings } from 'features/controlLayers/components/RefImage/RefImageSettings'; +import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; -import { selectRefImageEntityOrThrow, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; -import type { ImageWithDims } from 'features/controlLayers/store/types'; -import { memo, useMemo, useRef } from 'react'; +import { memo, useCallback, useRef } from 'react'; +import { PiImageBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -const sx: SystemStyleObject = { - opacity: 0.5, - _hover: { - opacity: 1, - }, - "&[data-is-open='true']": { - opacity: 1, - pointerEvents: 'none', - }, - transitionProperty: 'opacity', - transitionDuration: '0.2s', -}; +// There is some awkwardness here with closing the popover when clicking outside of it, related to Chakra's +// handling of refs, portals, outside clicks, and a race condition with framer-motion animations that can leave +// the popover closed when its internal state is still open. +// +// We have to manually manage the popover open state to work around the race condition, and then have to do special +// handling to close the popover when clicking outside of it. + +// We have to reach outside react to identify the popover trigger element instead of using refs, thanks to how Chakra +// handles refs for PopoverAnchor internally. Maybe there is some way to merge them but I couldn't figure it out. +const getRefImagePopoverTriggerId = (id: string) => `ref-image-popover-trigger-${id}`; export const RefImage = memo(() => { const id = useRefImageIdContext(); const ref = useRef(null); const disclosure = useDisclosure(false); - const selectEntity = useMemo( - () => createSelector(selectRefImagesSlice, (refImages) => selectRefImageEntityOrThrow(refImages, id, 'RefImage')), + // This filter prevents the popover from closing when clicking on a sibling portal element, like the dropdown menu + // inside the ref image settings popover. It also prevents the popover from closing when clicking on the popover's + // own trigger element. + const filter = useCallback( + (el: HTMLElement | SVGElement) => { + return DEFAULT_FILTER(el) || el.id === getRefImagePopoverTriggerId(id); + }, [id] ); - const entity = useAppSelector(selectEntity); - useFilterableOutsideClick({ ref, handler: disclosure.close }); + useFilterableOutsideClick({ ref, handler: disclosure.close, filter }); return ( - - - - - - + + - + - + + + + + @@ -65,8 +80,58 @@ export const RefImage = memo(() => { }); RefImage.displayName = 'RefImage'; -const Thumbnail = memo(({ image }: { image: ImageWithDims | null }) => { - const { data: imageDTO } = useGetImageDTOQuery(image?.image_name ?? skipToken); - return ; +const imageSx: SystemStyleObject = { + opacity: 0.5, + _hover: { + opacity: 1, + }, + "&[data-is-open='true']": { + opacity: 1, + }, + transitionProperty: 'opacity', + transitionDuration: '0.2s', +}; + +const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => { + const id = useRefImageIdContext(); + const entity = useRefImageEntity(id); + const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken); + + if (!imageDTO || !entity.config.image) { + return ( + + } + colorScheme="error" + onClick={disclosure.toggle} + /> + + ); + } + return ( + + + + ); }); Thumbnail.displayName = 'Thumbnail'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx new file mode 100644 index 0000000000..1a2eb27310 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx @@ -0,0 +1,41 @@ +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; +import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { refImageDeleted } from 'features/controlLayers/store/refImagesSlice'; +import { memo, useCallback } from 'react'; +import { PiTrashBold } from 'react-icons/pi'; + +export const RefImageHeader = memo(() => { + const id = useRefImageIdContext(); + const dispatch = useAppDispatch(); + const entity = useRefImageEntity(id); + const deleteRefImage = useCallback(() => { + dispatch(refImageDeleted({ id })); + }, [dispatch, id]); + + return ( + + {entity.config.image !== null && ( + + Reference Image + + )} + {entity.config.image === null && ( + + Reference Image - No Image Selected + + )} + } + onClick={deleteRefImage} + aria-label="Delete reference image" + colorScheme="error" + /> + + ); +}); +RefImageHeader.displayName = 'RefImageHeader'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx new file mode 100644 index 0000000000..723237fee5 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx @@ -0,0 +1,77 @@ +/* eslint-disable i18next/no-literal-string */ +import type { FlexProps } from '@invoke-ai/ui-library'; +import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { RefImage } from 'features/controlLayers/components/RefImage/RefImage'; +import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { useAddGlobalReferenceImage } from 'features/controlLayers/hooks/addLayerHooks'; +import { selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; +import { memo } from 'react'; +import { PiPlusBold } from 'react-icons/pi'; + +export const RefImageList = memo((props: FlexProps) => { + const ids = useAppSelector(selectRefImageEntityIds); + const addRefImage = useAddGlobalReferenceImage(); + return ( + + {ids.map((id) => ( + + + + ))} + + + + ); +}); + +RefImageList.displayName = 'RefImageList'; + +const AddRefImageIconButton = memo(() => { + const addRefImage = useAddGlobalReferenceImage(); + return ( + } + /> + ); +}); +AddRefImageIconButton.displayName = 'AddRefImageIconButton'; + +const AddRefImageButton = memo((props) => { + const addRefImage = useAddGlobalReferenceImage(); + return ( + + ); +}); +AddRefImageButton.displayName = 'AddRefImageButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx index 3a82fd16f9..81aec80fec 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx @@ -2,8 +2,6 @@ import { Button, Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; -import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; -import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -17,7 +15,6 @@ export const RefImageNoImageState = memo(() => { const { t } = useTranslation(); const id = useRefImageIdContext(); const dispatch = useAppDispatch(); - const isBusy = useCanvasIsBusy(); const onUpload = useCallback( (imageDTO: ImageDTO) => { setGlobalReferenceImage({ imageDTO, id, dispatch }); @@ -28,7 +25,6 @@ export const RefImageNoImageState = memo(() => { const onClickGalleryButton = useCallback(() => { dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); - const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id); const dndTargetData = useMemo( () => setGlobalReferenceImageDndTarget.getData({ id }), @@ -37,17 +33,10 @@ export const RefImageNoImageState = memo(() => { const components = useMemo( () => ({ - UploadButton: ( - + {ids.length < 5 && } + {ids.length >= 5 && } ); }); RefImageList.displayName = 'RefImageList'; -const AddRefImageIconButton = memo(() => { - const addRefImage = useAddGlobalReferenceImage(); - return ( - } - /> - ); -}); -AddRefImageIconButton.displayName = 'AddRefImageIconButton'; +const dndTargetData = addGlobalReferenceImageDndTarget.getData(); -const AddRefImageButton = memo((props) => { - const addRefImage = useAddGlobalReferenceImage(); +const MaxRefImages = memo(() => { return ( ); }); -AddRefImageButton.displayName = 'AddRefImageButton'; +MaxRefImages.displayName = 'MaxRefImages'; + +const AddRefImageDropTargetAndButton = memo(() => { + const { dispatch, getState } = useAppStore(); + + const uploadOptions = useMemo( + () => + ({ + onUpload: (imageDTO: ImageDTO) => { + const config = getDefaultRefImageConfig(getState); + config.image = imageDTOToImageWithDims(imageDTO); + dispatch(refImageAdded({ overrides: { config } })); + }, + allowMultiple: false, + }) as const, + [dispatch, getState] + ); + + const uploadApi = useImageUploadButton(uploadOptions); + + return ( + <> + + + ); +}); +AddRefImageDropTargetAndButton.displayName = 'AddRefImageDropTargetAndButton'; diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index bb401727d3..df768f4ec6 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -1,7 +1,10 @@ import { logger } from 'app/logging/logger'; import type { AppDispatch, AppGetState } from 'app/store/store'; +import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types'; +import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; import type { BoardId } from 'features/gallery/store/types'; import { @@ -152,6 +155,34 @@ export const setGlobalReferenceImageDndTarget: DndTarget< }; //#endregion +//#region Add Global Reference Image +const _addGlobalReferenceImage = buildTypeAndKey('add-global-reference-image'); +export type AddGlobalReferenceImageDndTargetData = DndData< + typeof _addGlobalReferenceImage.type, + typeof _addGlobalReferenceImage.key +>; +export const addGlobalReferenceImageDndTarget: DndTarget< + AddGlobalReferenceImageDndTargetData, + SingleImageDndSourceData +> = { + ..._addGlobalReferenceImage, + typeGuard: buildTypeGuard(_addGlobalReferenceImage.key), + getData: buildGetData(_addGlobalReferenceImage.key, _addGlobalReferenceImage.type), + isValid: ({ sourceData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: ({ sourceData, dispatch, getState }) => { + const { imageDTO } = sourceData.payload; + const config = getDefaultRefImageConfig(getState); + config.image = imageDTOToImageWithDims(imageDTO); + dispatch(refImageAdded({ overrides: { config } })); + }, +}; +//#endregion + //#region Set Regional Guidance Reference Image const _setRegionalGuidanceReferenceImage = buildTypeAndKey('set-regional-guidance-reference-image'); export type SetRegionalGuidanceReferenceImageDndTargetData = DndData< @@ -496,6 +527,7 @@ export const dndTargets = [ addImageToBoardDndTarget, removeImageFromBoardDndTarget, newCanvasFromImageDndTarget, + addGlobalReferenceImageDndTarget, // Single or Multiple Image addImageToBoardDndTarget, removeImageFromBoardDndTarget, From 2e0824a799bff3c5166f5297d0430febf0b8fcce Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 16 Jun 2025 17:41:47 +1000 Subject: [PATCH 098/210] feat(ui): make autoswitch on/off When the invocation cache is used, we might skip all progress images. This can prevent auto-switch-on-first-progress from working, as we don't get any of those events. It's much easier to only support auto-switch on complete. --- .../components/SimpleSession/context.tsx | 14 +++----------- .../StagingAreaToolbarMenuAutoSwitch.tsx | 14 +++++--------- 2 files changed, 8 insertions(+), 20 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 94357c5e0f..88f6f4f2d0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -13,10 +13,6 @@ import { queueApi } from 'services/api/endpoints/queue'; import type { ImageDTO, S } from 'services/api/types'; import { $socket } from 'services/events/stores'; import { assert } from 'tsafe'; -import { z } from 'zod'; - -export const zAutoSwitchMode = z.enum(['off', 'first_progress', 'completed']); -export type AutoSwitchMode = z.infer; export type ProgressData = { itemId: number; @@ -91,7 +87,7 @@ type CanvasSessionContextValue = { $selectedItem: Atom; $selectedItemIndex: Atom; $selectedItemOutputImageDTO: Atom; - $autoSwitch: WritableAtom; + $autoSwitch: WritableAtom; $lastLoadedItemId: WritableAtom; selectNext: () => void; selectPrev: () => void; @@ -126,7 +122,7 @@ export const CanvasSessionContextProvider = memo( /** * Whether auto-switch is enabled. */ - const $autoSwitch = useState(() => atom('first_progress'))[0]; + const $autoSwitch = useState(() => atom(true))[0]; /** * An internal flag used to work around race conditions with auto-switch switching to queue items before their @@ -273,11 +269,7 @@ export const CanvasSessionContextProvider = memo( if (data.destination !== session.id) { return; } - const isFirstProgressImage = !$progressData.get()[data.item_id]?.progressImage && !!data.image; setProgress($progressData, data); - if ($autoSwitch.get() === 'first_progress' && isFirstProgressImage) { - $selectedItemId.set(data.item_id); - } }; socket.on('invocation_progress', onProgress); @@ -413,7 +405,7 @@ export const CanvasSessionContextProvider = memo( if (lastLoadedItemId === null) { return; } - if ($autoSwitch.get() === 'completed') { + if ($autoSwitch.get()) { $selectedItemId.set(lastLoadedItemId); } $lastLoadedItemId.set(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx index 567e6beb5e..ff8a83ff87 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx @@ -1,7 +1,7 @@ /* eslint-disable i18next/no-literal-string */ import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext, zAutoSwitchMode } from 'features/controlLayers/components/SimpleSession/context'; +import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { memo, useCallback } from 'react'; export const StagingAreaToolbarMenuAutoSwitch = memo(() => { @@ -10,22 +10,18 @@ export const StagingAreaToolbarMenuAutoSwitch = memo(() => { const onChange = useCallback( (val: string | string[]) => { - const newAutoSwitch = zAutoSwitchMode.parse(val); - ctx.$autoSwitch.set(newAutoSwitch); + ctx.$autoSwitch.set(val === 'on'); }, [ctx.$autoSwitch] ); return ( - + Off - - First Progress - - - Completed + + On ); From 893f7a87441d7b4e93bb32a340bd780954ec73cd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:25:37 +1000 Subject: [PATCH 099/210] fix(ui): progress image fixes --- .../SimpleSession/QueueItemPreviewFull.tsx | 19 ++-- .../SimpleSession/QueueItemPreviewMini.tsx | 19 ++-- .../SimpleSession/QueueItemStatusLabel.tsx | 28 ++++-- .../components/SimpleSession/context.tsx | 91 +++++++++++++------ 4 files changed, 104 insertions(+), 53 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx index ba45a9f2ba..a39d8f64bb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewFull.tsx @@ -1,6 +1,10 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; -import { useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/context'; +import { + useCanvasSessionContext, + useOutputImageDTO, + useProgressData, +} from 'features/controlLayers/components/SimpleSession/context'; import { ImageActions } from 'features/controlLayers/components/SimpleSession/ImageActions'; import { QueueItemCircularProgress } from 'features/controlLayers/components/SimpleSession/QueueItemCircularProgress'; import { QueueItemNumber } from 'features/controlLayers/components/SimpleSession/QueueItemNumber'; @@ -8,7 +12,7 @@ import { QueueItemProgressImage } from 'features/controlLayers/components/Simple import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { DndImage } from 'features/dnd/DndImage'; -import { memo, useCallback, useState } from 'react'; +import { memo } from 'react'; import type { S } from 'services/api/types'; type Props = { @@ -27,17 +31,14 @@ const sx = { } satisfies SystemStyleObject; export const QueueItemPreviewFull = memo(({ item, number }: Props) => { + const ctx = useCanvasSessionContext(); const imageDTO = useOutputImageDTO(item); - const [imageLoaded, setImageLoaded] = useState(false); - - const onLoad = useCallback(() => { - setImageLoaded(true); - }, []); + const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id); return ( - - {imageDTO && } + + {imageDTO && } {!imageLoaded && } {imageDTO && } 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 cd69cd1bdb..420abee061 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -1,13 +1,17 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; -import { useCanvasSessionContext, useOutputImageDTO } from 'features/controlLayers/components/SimpleSession/context'; +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'; import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { DndImage } from 'features/dnd/DndImage'; -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback } from 'react'; import type { S } from 'services/api/types'; const sx = { @@ -35,7 +39,7 @@ type Props = { export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) => { const ctx = useCanvasSessionContext(); - const [imageLoaded, setImageLoaded] = useState(false); + const { imageLoaded } = useProgressData(ctx.$progressData, item.item_id); const imageDTO = useOutputImageDTO(item); const onClick = useCallback(() => { @@ -43,15 +47,12 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = }, [ctx.$selectedItemId, item.item_id]); const onLoad = useCallback(() => { - setImageLoaded(true); - if (ctx.$progressData.get()[item.item_id]) { - ctx.$lastLoadedItemId.set(item.item_id); - } - }, [ctx.$lastLoadedItemId, ctx.$progressData, item.item_id]); + ctx.onImageLoad(item.item_id); + }, [ctx, item.item_id]); return ( - + {imageDTO && } {!imageLoaded && } 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 56cb4c92c0..13364cac00 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx @@ -1,27 +1,35 @@ /* eslint-disable i18next/no-literal-string */ 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'; -type Props = { status: S['SessionQueueItem']['status'] } & TextProps; +type Props = { item: S['SessionQueueItem'] } & TextProps; -export const QueueItemStatusLabel = memo(({ status, ...rest }: Props) => { - if (status === 'pending') { +export const QueueItemStatusLabel = memo(({ item, ...rest }: Props) => { + const ctx = useCanvasSessionContext(); + const { progressImage, imageLoaded } = useProgressData(ctx.$progressData, item.item_id); + + if (progressImage || imageLoaded) { + return null; + } + + if (item.status === 'pending') { return ( Pending ); } - if (status === 'canceled') { + if (item.status === 'canceled') { return ( Canceled ); } - if (status === 'failed') { + if (item.status === 'failed') { return ( Failed @@ -29,7 +37,7 @@ export const QueueItemStatusLabel = memo(({ status, ...rest }: Props) => { ); } - if (status === 'in_progress') { + if (item.status === 'in_progress') { return ( In Progress @@ -37,6 +45,14 @@ export const QueueItemStatusLabel = memo(({ status, ...rest }: Props) => { ); } + if (item.status === 'completed') { + return ( + + Completed + + ); + } + return null; }); QueueItemStatusLabel.displayName = 'QueueItemStatusLabel'; 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 88f6f4f2d0..8d9a77b163 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -4,21 +4,22 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; import type { ProgressImage } from 'features/nodes/types/common'; -import type { Atom, WritableAtom } from 'nanostores'; -import { atom, computed, effect } from 'nanostores'; +import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores'; +import { atom, computed, effect, 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 } from 'tsafe'; +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 => ({ @@ -26,17 +27,17 @@ const getInitialProgressData = (itemId: number): ProgressData => ({ progressEvent: null, progressImage: null, imageDTO: null, + imageLoaded: false, }); -export const useProgressData = ( - $progressData: WritableAtom>, - itemId: number -): ProgressData => { - const [value, setValue] = useState(() => { - return $progressData.get()[itemId] ?? getInitialProgressData(itemId); - }); +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 = $progressData.subscribe((data) => { + const unsub = subscribeKeys($progressData, [itemId], (data) => { const progressData = data[itemId]; if (!progressData) { return; @@ -51,7 +52,7 @@ export const useProgressData = ( return value; }; -const setProgress = ($progressData: WritableAtom>, data: S['InvocationProgressEvent']) => { +const setProgress = ($progressData: ProgressDataMap, data: S['InvocationProgressEvent']) => { const progressData = $progressData.get(); const current = progressData[data.item_id]; if (current) { @@ -72,27 +73,30 @@ const setProgress = ($progressData: WritableAtom>, progressEvent: data, progressImage: data.image ?? null, imageDTO: null, + imageLoaded: false, }, }); } }; +type ProgressDataMap = MapStore>; + type CanvasSessionContextValue = { session: { id: string; type: 'simple' | 'advanced' }; $items: Atom; $itemCount: Atom; $hasItems: Atom; - $progressData: WritableAtom>; + $progressData: ProgressDataMap; $selectedItemId: WritableAtom; $selectedItem: Atom; $selectedItemIndex: Atom; $selectedItemOutputImageDTO: Atom; $autoSwitch: WritableAtom; - $lastLoadedItemId: WritableAtom; selectNext: () => void; selectPrev: () => void; selectFirst: () => void; selectLast: () => void; + onImageLoad: (itemId: number) => void; }; const CanvasSessionContext = createContext(null); @@ -112,6 +116,7 @@ export const CanvasSessionContextProvider = memo( const store = useAppStore(); const socket = useStore($socket); + const $lastCompletedItemId = useState(() => atom(null))[0]; /** * Manually-synced atom containing queue items for the current session. This is populated from the RTK Query cache @@ -133,7 +138,7 @@ export const CanvasSessionContextProvider = memo( /** * An ephemeral store of progress events and images for all items in the current session. */ - const $progressData = useState(() => atom>({}))[0]; + const $progressData = useState(() => map>({}))[0]; /** * The currently selected queue item's ID, or null if one is not selected. @@ -259,6 +264,27 @@ export const CanvasSessionContextProvider = memo( $selectedItemId.set(last.item_id); }, [$items, $selectedItemId]); + const onImageLoad = useCallback( + (itemId: number) => { + const progressData = $progressData.get(); + const current = progressData[itemId]; + if (current) { + const next = { ...current, imageLoaded: true }; + $progressData.setKey(itemId, next); + } else { + $progressData.setKey(itemId, { + ...getInitialProgressData(itemId), + imageLoaded: true, + }); + } + if ($lastCompletedItemId.get() === itemId) { + $selectedItemId.set(itemId); + $lastCompletedItemId.set(null); + } + }, + [$lastCompletedItemId, $progressData, $selectedItemId] + ); + // Set up socket listeners useEffect(() => { if (!socket) { @@ -272,12 +298,23 @@ export const CanvasSessionContextProvider = memo( setProgress($progressData, data); }; + const onQueueItemStatusChanged = (data: S['QueueItemStatusChangedEvent']) => { + if (data.destination !== session.id) { + return; + } + if (data.status === 'completed') { + $lastCompletedItemId.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); }; - }, [$autoSwitch, $progressData, $selectedItemId, session.id, socket]); + }, [$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId, session.id, socket]); // Set up state subscriptions and effects useEffect(() => { @@ -327,7 +364,11 @@ export const CanvasSessionContextProvider = memo( const toDelete: number[] = []; const toUpdate: ProgressData[] = []; - for (const datum of Object.values(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); @@ -376,21 +417,13 @@ export const CanvasSessionContextProvider = memo( } } - if (toDelete.length === 0 && toUpdate.length === 0) { - return; - } - - const newProgressData = { ...progressData }; - for (const itemId of toDelete) { - delete newProgressData[itemId]; + $progressData.setKey(itemId, undefined); } for (const datum of toUpdate) { - newProgressData[datum.itemId] = datum; + $progressData.setKey(datum.itemId, datum); } - - $progressData.set(newProgressData); }); // We only want to auto-switch to completed queue items once their images have fully loaded to prevent flashes @@ -440,19 +473,18 @@ export const CanvasSessionContextProvider = memo( $autoSwitch, $selectedItem, $selectedItemIndex, - $lastLoadedItemId, $selectedItemOutputImageDTO, $itemCount, selectNext, selectPrev, selectFirst, selectLast, + onImageLoad, }), [ $autoSwitch, $items, $hasItems, - $lastLoadedItemId, $progressData, $selectedItem, $selectedItemId, @@ -464,6 +496,7 @@ export const CanvasSessionContextProvider = memo( selectPrev, selectFirst, selectLast, + onImageLoad, ] ); From c31cb0b106ec5022d0aa96f888c10fae19d19bb6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:27:29 +1000 Subject: [PATCH 100/210] fix(ui): invoke button tooltip on generate tab --- .../components/InvokeButtonTooltip/InvokeButtonTooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx index 68992a84f4..04e9d30441 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx @@ -35,7 +35,7 @@ export const InvokeButtonTooltip = ({ prepend, children, ...rest }: PropsWithChi const TooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => { const activeTab = useAppSelector(selectActiveTab); - if (activeTab === 'canvas') { + if (activeTab === 'canvas' || activeTab === 'generate') { return ; } From 161624c722f95cc7e5121d7772278eb6d507ef6e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:59:01 +1000 Subject: [PATCH 101/210] feat(ui): rework simple session initial state --- .../components/SimpleSession/InitialState.tsx | 52 +++++++++---------- .../InitialStateAddAStyleReference.tsx | 41 +++++++++------ .../InitialStateButtonGridItem.tsx | 14 +++-- .../InitialStateGenerateFromText.tsx | 10 ++-- .../InitialStateMainModelPicker.tsx | 48 +++++++++++++++++ 5 files changed, 109 insertions(+), 56 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx index 2c8f92af6f..d8cf7aa2c6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx @@ -1,12 +1,10 @@ /* eslint-disable i18next/no-literal-string */ -import { Button, Divider, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; +import { Alert, Button, Divider, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { InitialStateAddAStyleReference } from 'features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference'; -import { InitialStateEditImageCard } from 'features/controlLayers/components/SimpleSession/InitialStateEditImageCard'; import { InitialStateGenerateFromText } from 'features/controlLayers/components/SimpleSession/InitialStateGenerateFromText'; -import { InitialStateUseALayoutImageCard } from 'features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard'; -import { toast } from 'features/toast/toast'; +import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; @@ -14,14 +12,6 @@ export const InitialState = memo(() => { const dispatch = useAppDispatch(); const newCanvasSession = useCallback(() => { dispatch(setActiveTab('canvas')); - toast({ - title: 'Switched to Canvas', - description: 'You are in advanced mode yadda yadda.', - status: 'info', - position: 'top', - // isClosable: false, - duration: 5000, - }); }, [dispatch]); return ( @@ -31,24 +21,30 @@ export const InitialState = memo(() => { - Choose a starting method. - - Drag an image onto a card or click the upload icon. - - - + Get started with Invoke. + + + + + + Want to learn what prompts work best for each model?{' '} + + + + - - - - - - or{' '} - - + + + Looking to get more control, edit, and iterate on your images? + + + + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx index 0b97e5637e..9e23714002 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx @@ -4,34 +4,43 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem'; -import { newCanvasFromImageDndTarget } from 'features/dnd/dnd'; +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 { newCanvasFromImage } from 'features/imageActions/actions'; -import { memo, useCallback } from 'react'; +import { memo, useMemo } from 'react'; import { PiUploadBold, PiUserCircleGearBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; -const NEW_CANVAS_OPTIONS = { type: 'reference_image' } as const; - -const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS); +const dndTargetData = addGlobalReferenceImageDndTarget.getData(); export const InitialStateAddAStyleReference = memo(() => { - const { getState, dispatch } = useAppStore(); + const { dispatch, getState } = useAppStore(); - const onUpload = useCallback( - (imageDTO: ImageDTO) => { - newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS }); - }, + const uploadOptions = useMemo( + () => + ({ + onUpload: (imageDTO: ImageDTO) => { + const config = getDefaultRefImageConfig(getState); + config.image = imageDTOToImageWithDims(imageDTO); + dispatch(refImageAdded({ overrides: { config } })); + }, + allowMultiple: false, + }) as const, [dispatch, getState] ); - const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); + + const uploadApi = useImageUploadButton(uploadOptions); return ( - + - Add a Style Reference - Add an image to transfer its look. - + + Add a Style Reference + Add an image to transfer its look. + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx index b53e059843..c7b85239d9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx @@ -1,21 +1,19 @@ -import type { GridItemProps } from '@invoke-ai/ui-library'; -import { Button, forwardRef, GridItem } from '@invoke-ai/ui-library'; +import type { ButtonProps } from '@invoke-ai/ui-library'; +import { Button, forwardRef } from '@invoke-ai/ui-library'; import { memo } from 'react'; export const InitialStateButtonGridItem = memo( - forwardRef(({ children, ...rest }: GridItemProps, ref) => { + forwardRef(({ children, ...rest }: ButtonProps, ref) => { return ( - {children} - + ); }) ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx index 67048c383f..d51b06cd5a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx @@ -15,11 +15,13 @@ const focusOnPrompt = () => { export const InitialStateGenerateFromText = memo(() => { return ( - + - Generate from Text - Enter a prompt and Invoke. - + + Generate from Text + Enter a prompt and Invoke. + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx new file mode 100644 index 0000000000..421b275cd2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx @@ -0,0 +1,48 @@ +/* eslint-disable i18next/no-literal-string */ +import { Flex, FormControl, FormLabel, Icon } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { ModelPicker } from 'features/parameters/components/ModelPicker'; +import { modelSelected } from 'features/parameters/store/actions'; +import { memo, useCallback, useMemo } from 'react'; +import { MdMoneyOff } from 'react-icons/md'; +import { useMainModels } from 'services/api/hooks/modelsByType'; +import { useSelectedModelConfig } from 'services/api/hooks/useSelectedModelConfig'; +import { type AnyModelConfig, isCheckpointMainModelConfig } from 'services/api/types'; + +export const InitialStateMainModelPicker = memo(() => { + const dispatch = useAppDispatch(); + const [modelConfigs] = useMainModels(); + const selectedModelConfig = useSelectedModelConfig(); + const onChange = useCallback( + (modelConfig: AnyModelConfig) => { + dispatch(modelSelected(modelConfig)); + }, + [dispatch] + ); + + const isFluxDevSelected = useMemo( + () => + selectedModelConfig && + isCheckpointMainModelConfig(selectedModelConfig) && + selectedModelConfig.config_path === 'flux-dev', + [selectedModelConfig] + ); + + return ( + + + Select your Model + + {isFluxDevSelected && ( + + + + + + )} + + + ); +}); +InitialStateMainModelPicker.displayName = 'InitialStateMainModelPicker'; From 5ac5115269cd81a66862a51ef487b37a79ef2fc1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 16 Jun 2025 19:02:01 +1000 Subject: [PATCH 102/210] chore(ui): lint --- invokeai/frontend/web/.eslintrc.js | 3 ++- .../AdvancedSession/AdvancedSession.tsx | 1 - .../components/CanvasAddEntityButtons.tsx | 2 +- ...nvasContextMenuSelectedEntityMenuItems.tsx | 2 +- .../EntityListGlobalActionBarAddLayerMenu.tsx | 2 +- .../components/CanvasMainPanelContent.tsx | 23 ------------------- .../components/RefImage/RefImageList.tsx | 1 - ...onalGuidanceAddPromptsIPAdapterButtons.tsx | 2 +- .../RegionalGuidanceIPAdapterSettings.tsx | 4 ++-- ...uidanceMenuItemsAddPromptsAndIPAdapter.tsx | 2 +- .../components/SimpleSession/ImageActions.tsx | 1 - .../components/SimpleSession/InitialState.tsx | 2 -- .../InitialStateAddAStyleReference.tsx | 2 -- .../InitialStateEditImageCard.tsx | 2 -- .../InitialStateGenerateFromText.tsx | 2 -- .../InitialStateMainModelPicker.tsx | 1 - .../InitialStateUseALayoutImageCard.tsx | 2 -- .../QueueItemProgressMessage.tsx | 1 - .../SimpleSession/QueueItemStatusLabel.tsx | 1 - .../components/SimpleSession/StagingArea.tsx | 2 -- .../SimpleSession/StagingAreaContent.tsx | 1 - .../SimpleSession/StagingAreaHeader.tsx | 1 - .../SimpleSession/StagingAreaItemsList.tsx | 1 - .../SimpleSession/StagingAreaNoItems.tsx | 2 -- .../SimpleSession/StagingAreaSelectedItem.tsx | 1 - .../components/SimpleSession/context.tsx | 2 +- .../StagingAreaToolbarMenuAutoSwitch.tsx | 1 - .../components/StartOverButton.tsx | 4 ---- .../components/Toolbar/CanvasToolbar.tsx | 1 - .../components/common/CanvasEntityHeader.tsx | 2 +- .../useNextRenderableEntityIdentifier.ts | 4 +--- .../CanvasEntity/CanvasEntityAdapterBase.ts | 5 +--- .../CanvasEntity/CanvasEntityFilterer.ts | 2 +- .../konva/CanvasSegmentAnythingModule.ts | 2 +- .../konva/CanvasStagingAreaModule.ts | 9 +++----- .../util/graph/generation/addIPAdapters.ts | 2 +- .../Core/NegativePromptToggleButton.tsx | 2 -- .../components/Core/ParamNegativePrompt.tsx | 5 +--- .../components/InvokeAILogoComponent.tsx | 1 - .../features/ui/components/VerticalNavBar.tsx | 9 +++++++- 40 files changed, 28 insertions(+), 87 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js index ed08009e66..3e6498af4c 100644 --- a/invokeai/frontend/web/.eslintrc.js +++ b/invokeai/frontend/web/.eslintrc.js @@ -9,7 +9,8 @@ module.exports = { // https://github.com/qdanik/eslint-plugin-path 'path/no-relative-imports': ['error', { maxDepth: 0 }], // https://github.com/edvardchen/eslint-plugin-i18next/blob/HEAD/docs/rules/no-literal-string.md - 'i18next/no-literal-string': 'error', + // TODO: ENABLE THIS RULE BEFORE v6.0.0 + // 'i18next/no-literal-string': 'error', // https://eslint.org/docs/latest/rules/no-console 'no-console': 'error', // https://eslint.org/docs/latest/rules/no-promise-executor-return diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx index 88fc045919..1628b25697 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { ContextMenu, Divider, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx index d3475a385c..88b5b6d37e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAddEntityButtons.tsx @@ -3,9 +3,9 @@ import { InformationalPopover } from 'common/components/InformationalPopover/Inf import { useAddControlLayer, useAddInpaintMask, + useAddNewRegionalGuidanceWithARefImage, useAddRasterLayer, useAddRegionalGuidance, - useAddNewRegionalGuidanceWithARefImage, } from 'features/controlLayers/hooks/addLayerHooks'; import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx index 8d150e0bb7..049853e3f2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx @@ -2,8 +2,8 @@ import { MenuGroup } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { ControlLayerMenuItems } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItems'; import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItems'; -import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems'; import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems'; +import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems'; import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems'; import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate'; import { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx index 04994f200e..4a7e6fa375 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx @@ -2,9 +2,9 @@ import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@in import { useAddControlLayer, useAddInpaintMask, + useAddNewRegionalGuidanceWithARefImage, useAddRasterLayer, useAddRegionalGuidance, - useAddNewRegionalGuidanceWithARefImage, } from 'features/controlLayers/hooks/addLayerHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx deleted file mode 100644 index b42a2f716a..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useAppSelector } from 'app/store/storeHooks'; -import { AdvancedSession } from 'features/controlLayers/components/AdvancedSession/AdvancedSession'; -import { SimpleSession } from 'features/controlLayers/components/SimpleSession/SimpleSession'; -import { selectCanvasSessionId, selectCanvasSessionType } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { memo } from 'react'; -import type { Equals } from 'tsafe'; -import { assert } from 'tsafe'; - -export const CanvasMainPanelContent = memo(() => { - const type = useAppSelector(selectCanvasSessionType); - const id = useAppSelector(selectCanvasSessionId); - - if (type === 'simple') { - return ; - } - - if (type === 'advanced') { - return ; - } - - assert>(false, 'Unexpected session type'); -}); -CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx index d7efcb3d0b..76b1624b11 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import type { FlexProps } from '@invoke-ai/ui-library'; import { Button, Flex } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx index cb88c281f7..2ffd494e16 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx @@ -3,9 +3,9 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { buildSelectValidRegionalGuidanceActions, - useAddRefImageToExistingRegionalGuidance, useAddNegativePromptToExistingRegionalGuidance, useAddPositivePromptToExistingRegionalGuidance, + useAddRefImageToExistingRegionalGuidance, } from 'features/controlLayers/hooks/addLayerHooks'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 70445b484b..c49a5a1658 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -2,11 +2,11 @@ import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; -import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel'; import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence'; +import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel'; import { Weight } from 'features/controlLayers/components/common/Weight'; -import { RefImageImage } from 'features/controlLayers/components/RefImage/RefImageImage'; import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod'; +import { RefImageImage } from 'features/controlLayers/components/RefImage/RefImageImage'; import { RegionalGuidanceIPAdapterSettingsEmptyState } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState'; import { RegionalReferenceImageModel } from 'features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx index de80b387fc..c8c43089b8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx @@ -3,9 +3,9 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { buildSelectValidRegionalGuidanceActions, - useAddRefImageToExistingRegionalGuidance, useAddNegativePromptToExistingRegionalGuidance, useAddPositivePromptToExistingRegionalGuidance, + useAddRefImageToExistingRegionalGuidance, } from 'features/controlLayers/hooks/addLayerHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { memo, useMemo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx index 7e877afce4..2bfeec992e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/ImageActions.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import type { ButtonGroupProps } from '@invoke-ai/ui-library'; import { Button, ButtonGroup } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx index d8cf7aa2c6..6229654c6c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx @@ -1,5 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ - import { Alert, Button, Divider, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { InitialStateAddAStyleReference } from 'features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx index 9e23714002..9a1c53a0a4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx @@ -1,5 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ - import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx index b6448f41d0..c5d566fb82 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateEditImageCard.tsx @@ -1,5 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ - import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx index d51b06cd5a..b684ba60ec 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateGenerateFromText.tsx @@ -1,5 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ - import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx index 421b275cd2..ac875b8232 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateMainModelPicker.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { Flex, FormControl, FormLabel, Icon } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx index 955ace441f..93e2e3b03d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateUseALayoutImageCard.tsx @@ -1,5 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ - import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx index fc60a93acf..2699383666 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemProgressMessage.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import type { TextProps } from '@invoke-ai/ui-library'; import { Text } from '@invoke-ai/ui-library'; import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; 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 13364cac00..3cf9194b9d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemStatusLabel.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import type { TextProps } from '@invoke-ai/ui-library'; import { Text } from '@invoke-ai/ui-library'; import { useCanvasSessionContext, useProgressData } from 'features/controlLayers/components/SimpleSession/context'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx index d00f6a4ec8..dbaf22649a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingArea.tsx @@ -1,5 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ - import { Divider, Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx index 0e5c230204..741619ee95 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaContent.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { Divider, Flex } from '@invoke-ai/ui-library'; import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; import { StagingAreaSelectedItem } from 'features/controlLayers/components/SimpleSession/StagingAreaSelectedItem'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx index 480b9f1659..64d59855fd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaHeader.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { Flex, Heading, Spacer } from '@invoke-ai/ui-library'; import { memo } from 'react'; 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 36c6d21ec8..519898d2cd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaItemsList.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx index 7c85c52c7a..087650c74c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaNoItems.tsx @@ -1,5 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ - import { Flex, Text } from '@invoke-ai/ui-library'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx index 67f8143529..49dd40a84c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/StagingAreaSelectedItem.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; 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 8d9a77b163..3f55bfe252 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -79,7 +79,7 @@ const setProgress = ($progressData: ProgressDataMap, data: S['InvocationProgress } }; -type ProgressDataMap = MapStore>; +export type ProgressDataMap = MapStore>; type CanvasSessionContextValue = { session: { id: string; type: 'simple' | 'advanced' }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx index ff8a83ff87..b67eefa9cf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx index 3557f91590..ae3ef6c84e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StartOverButton.tsx @@ -1,12 +1,8 @@ -/* eslint-disable i18next/no-literal-string */ import { Button } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; import { $simpleId } from 'features/ui/components/MainPanelContent'; import { memo, useCallback } from 'react'; export const StartOverButton = memo(() => { - const dispatch = useAppDispatch(); - const startOver = useCallback(() => { // dispatch(canvasSessionTypeChanged({ type: 'simple' })); $simpleId.set(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 91b02351c3..a233a14f13 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { Divider, Flex, Heading } from '@invoke-ai/ui-library'; import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover'; import { ToolColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx index 8c8e07cdc9..86d76fa58e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityHeader.tsx @@ -2,8 +2,8 @@ import type { FlexProps } from '@invoke-ai/ui-library'; import { ContextMenu, Flex, MenuList } from '@invoke-ai/ui-library'; import { ControlLayerMenuItems } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItems'; import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItems'; -import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems'; import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems'; +import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems'; import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { memo, useCallback } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts index f6328f20b7..6552f4f2e5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useNextRenderableEntityIdentifier.ts @@ -5,9 +5,7 @@ import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types' import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { useMemo } from 'react'; -export const useEntityIdentifierBelowThisOne = ( - entityIdentifier: T -): T | null => { +export const useEntityIdentifierBelowThisOne = (entityIdentifier: T): T | null => { const selector = useMemo( () => createMemoizedSelector(selectCanvasSlice, (canvas) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts index c89f9e376a..9ca0e7772a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts @@ -41,10 +41,7 @@ import stableHash from 'stable-hash'; import { assert } from 'tsafe'; import type { Jsonifiable, JsonObject } from 'type-fest'; -export abstract class CanvasEntityAdapterBase< - T extends CanvasEntityState, - U extends string, -> extends CanvasModuleBase { +export abstract class CanvasEntityAdapterBase extends CanvasModuleBase { readonly type: U; readonly id: string; readonly path: string[]; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts index e33eda59c4..38c7e664e1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer.ts @@ -9,7 +9,7 @@ import { addCoords, getKonvaNodeDebugAttrs, getPrefixedId } from 'features/contr import { selectAutoProcess } from 'features/controlLayers/store/canvasSettingsSlice'; import type { FilterConfig } from 'features/controlLayers/store/filters'; import { getFilterForModel, IMAGE_FILTERS } from 'features/controlLayers/store/filters'; -import type { CanvasImageState, CanvasEntityType } from 'features/controlLayers/store/types'; +import type { CanvasEntityType, CanvasImageState } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/util'; import { toast } from 'features/toast/toast'; import Konva from 'konva'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts index d8a0e2154c..593bd235c4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts @@ -15,8 +15,8 @@ import { } from 'features/controlLayers/konva/util'; import { selectAutoProcess } from 'features/controlLayers/store/canvasSettingsSlice'; import type { - CanvasImageState, CanvasEntityType, + CanvasImageState, Coordinate, RgbaColor, SAMPointLabel, diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index 8262c70886..709fb2cbe4 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 } from 'features/controlLayers/components/SimpleSession/context'; +import type { ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; @@ -7,7 +7,7 @@ import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import type { CanvasImageState } from 'features/controlLayers/store/types'; import Konva from 'konva'; -import type { Atom, WritableAtom } from 'nanostores'; +import type { Atom } from 'nanostores'; import { atom, effect } from 'nanostores'; import type { Logger } from 'roarr'; @@ -135,10 +135,7 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging)); }; - connectToSession = ( - $selectedItemId: Atom, - $progressData: WritableAtom> - ) => + connectToSession = ($selectedItemId: Atom, $progressData: ProgressDataMap) => effect([$selectedItemId, $progressData], (selectedItemId, progressData) => { if (!selectedItemId) { this.$imageSrc.set(null); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts index 49e94aa03f..ab5fb74465 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts @@ -1,4 +1,4 @@ -import { type IPAdapterConfig, isIPAdapterConfig,type RefImageState } from 'features/controlLayers/store/types'; +import { type IPAdapterConfig, isIPAdapterConfig, type RefImageState } from 'features/controlLayers/store/types'; import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import type { Invocation, MainModelConfig } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/NegativePromptToggleButton.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/NegativePromptToggleButton.tsx index 7283c76b25..9733abf712 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/NegativePromptToggleButton.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/NegativePromptToggleButton.tsx @@ -2,14 +2,12 @@ import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { negativePromptChanged, selectHasNegativePrompt } from 'features/controlLayers/store/paramsSlice'; import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { PiPlusMinusBold } from 'react-icons/pi'; export const NegativePromptToggleButton = memo(() => { const hasNegativePrompt = useAppSelector(selectHasNegativePrompt); const dispatch = useAppDispatch(); - const { t } = useTranslation(); const onClick = useCallback(() => { if (hasNegativePrompt) { diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx index 94faaaa0cb..f18d1238ea 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx @@ -1,10 +1,7 @@ import { Box, Textarea } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize'; -import { - negativePromptChanged, - selectNegativePromptWithFallback, -} from 'features/controlLayers/store/paramsSlice'; +import { negativePromptChanged, selectNegativePromptWithFallback } from 'features/controlLayers/store/paramsSlice'; import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel'; import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper'; import { ViewModePrompt } from 'features/parameters/components/Prompts/ViewModePrompt'; diff --git a/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx b/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx index 91d4382a52..f5817fc658 100644 --- a/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx +++ b/invokeai/frontend/web/src/features/system/components/InvokeAILogoComponent.tsx @@ -1,4 +1,3 @@ -/* eslint-disable i18next/no-literal-string */ import { Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $logo } from 'app/store/nanostores/logo'; diff --git a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx index 29cde774d0..c2eacf45fa 100644 --- a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx +++ b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx @@ -8,7 +8,14 @@ import { VideosModalButton } from 'features/system/components/VideosModal/Videos import { TabMountGate } from 'features/ui/components/TabMountGate'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiBoundingBoxBold, PiCubeBold, PiFlowArrowBold, PiFrameCornersBold, PiQueueBold, PiTextAaBold } from 'react-icons/pi'; +import { + PiBoundingBoxBold, + PiCubeBold, + PiFlowArrowBold, + PiFrameCornersBold, + PiQueueBold, + PiTextAaBold, +} from 'react-icons/pi'; import { Notifications } from './Notifications'; import { TabButton } from './TabButton'; From 2f9ea9189694d085ef9240f0f6e295521069c342 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:32:29 +1000 Subject: [PATCH 103/210] feat(ui): tweak splash screen layout --- .../components/SimpleSession/InitialState.tsx | 12 +++++------ .../InitialStateMainModelPicker.tsx | 20 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx index 6229654c6c..dc968e5a51 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx @@ -13,17 +13,17 @@ export const InitialState = memo(() => { }, [dispatch]); return ( - - + + Get Started - + Get started with Invoke. - - + + - + Want to learn what prompts work best for each model?{' '} - - - - - - - } - colorScheme={boardSearchDisclosure.isOpen ? 'invokeBlue' : 'base'} - /> - + return ( + + + - ); - } -); + + + + + + } + colorScheme={boardSearchDisclosure.isOpen ? 'invokeBlue' : 'base'} + /> + + + ); +}); GalleryTopBar.displayName = 'GalleryTopBar'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/useGallerySearchTerm.ts b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/useGallerySearchTerm.ts index b9651e3d8e..6071d8a8f8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/useGallerySearchTerm.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/useGallerySearchTerm.ts @@ -1,5 +1,4 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { selectSearchTerm } from 'features/gallery/store/gallerySelectors'; import { searchTermChanged } from 'features/gallery/store/gallerySlice'; import { debounce } from 'lodash-es'; @@ -7,7 +6,7 @@ import { useCallback, useMemo, useState } from 'react'; export const useGallerySearchTerm = () => { // Highlander! - useAssertSingleton('gallery-search-state'); + // useAssertSingleton('gallery-search-state'); const dispatch = useAppDispatch(); const searchTerm = useAppSelector(selectSearchTerm); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx new file mode 100644 index 0000000000..9e323bd920 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx @@ -0,0 +1,110 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; +import { DndImage } from 'features/dnd/DndImage'; +import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; +import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; +import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; +import type { AnimationProps } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; +import { memo, useCallback, useRef, useState } from 'react'; +import type { ImageDTO } from 'services/api/types'; +import { $hasLastProgressImage } from 'services/events/stores'; + +import { NoContentForViewer } from './NoContentForViewer'; + +export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { + const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); + + // Show and hide the next/prev buttons on mouse move + const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); + const timeoutId = useRef(0); + const onMouseOver = useCallback(() => { + setShouldShowNextPrevButtons(true); + window.clearTimeout(timeoutId.current); + }, []); + const onMouseOut = useCallback(() => { + timeoutId.current = window.setTimeout(() => { + setShouldShowNextPrevButtons(false); + }, 500); + }, []); + + return ( + + + + + + {shouldShowImageDetails && imageDTO && ( + + + + )} + + {shouldShowNextPrevButtons && imageDTO && ( + + + + )} + + + ); +}); +CurrentImagePreview.displayName = 'CurrentImagePreview'; + +const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { + const hasProgressImage = useStore($hasLastProgressImage); + const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + + if (!imageDTO) { + return ; + } + + return ( + + + + ); +}); +ImageContent.displayName = 'ImageContent'; + +const initial: AnimationProps['initial'] = { + opacity: 0, +}; +const animateArrows: AnimationProps['animate'] = { + opacity: 1, + transition: { duration: 0.07 }, +}; +const exit: AnimationProps['exit'] = { + opacity: 0, + transition: { duration: 0.07 }, +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx new file mode 100644 index 0000000000..07cab441af --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx @@ -0,0 +1,125 @@ +import { Box, Flex, IconButton, type SystemStyleObject, useOutsideClick } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common'; +import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview2'; +import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison'; +import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; +import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; +import { memo, useRef } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useTranslation } from 'react-i18next'; +import { PiXBold } from 'react-icons/pi'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; + +import { useImageViewer } from './useImageViewer'; + +// type Props = { +// closeButton?: ReactNode; +// }; + +// const useFocusRegionOptions = { +// focusOnMount: true, +// }; + +// const FOCUS_REGION_STYLES: SystemStyleObject = { +// display: 'flex', +// width: 'full', +// height: 'full', +// position: 'absolute', +// flexDirection: 'column', +// inset: 0, +// alignItems: 'center', +// justifyContent: 'center', +// overflow: 'hidden', +// }; + +export const ImageViewer = memo(() => { + const lastSelectedImageName = useAppSelector(selectLastSelectedImageName); + const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken); + const comparisonImageDTO = useAppSelector(selectImageToCompare); + + if (lastSelectedImageDTO && comparisonImageDTO) { + return ; + } + + return ; +}); + +ImageViewer.displayName = 'ImageViewer'; + +const imageViewerContainerSx: SystemStyleObject = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + transition: 'opacity 0.15s ease', + opacity: 1, + pointerEvents: 'auto', + '&[data-hidden="true"]': { + opacity: 0, + pointerEvents: 'none', + }, + backdropFilter: 'blur(10px) brightness(70%)', +}; + +export const ImageViewerModal = memo(() => { + const ref = useRef(null); + const imageViewer = useImageViewer(); + useOutsideClick({ + ref, + handler: imageViewer.close, + }); + + useHotkeys( + 'esc', + imageViewer.close, + { + preventDefault: true, + enabled: imageViewer.isOpen, + }, + [imageViewer.isOpen] + ); + + return ( + + + + + + + ); +}); + +ImageViewerModal.displayName = 'GatedImageViewer'; + +const ImageViewerCloseButton = memo(() => { + const { t } = useTranslation(); + const imageViewer = useImageViewer(); + useAssertSingleton('ImageViewerCloseButton'); + useHotkeys('esc', imageViewer.close); + return ( + } + variant="link" + alignSelf="stretch" + onClick={imageViewer.close} + /> + ); +}); + +ImageViewerCloseButton.displayName = 'ImageViewerCloseButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx new file mode 100644 index 0000000000..850ebc63e1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx @@ -0,0 +1,56 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex, Image } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { selectSystemSlice } from 'features/system/store/systemSlice'; +import { memo, useMemo } from 'react'; +import { PiPulseBold } from 'react-icons/pi'; +import { $lastProgressImage } from 'services/events/stores'; + +const selectShouldAntialiasProgressImage = createSelector( + selectSystemSlice, + (system) => system.shouldAntialiasProgressImage +); + +export const ProgressImage = memo(() => { + const progressImage = useStore($lastProgressImage); + const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage); + + const sx = useMemo( + () => ({ + imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated', + }), + [shouldAntialiasProgressImage] + ); + + if (!progressImage) { + return ( + + + + ); + } + + return ( + + + + ); +}); + +ProgressImage.displayName = 'ProgressImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar2.tsx new file mode 100644 index 0000000000..f8dc34d654 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar2.tsx @@ -0,0 +1,18 @@ +import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; +import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton'; +import { memo } from 'react'; + +import CurrentImageButtons from './CurrentImageButtons'; + +export const ViewerToolbar = memo(() => { + return ( + + + + + + + ); +}); + +ViewerToolbar.displayName = 'ViewerToolbar'; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts index ab6da024fd..0f2a175c1c 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts @@ -1,6 +1,5 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation'; import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; @@ -14,7 +13,7 @@ import { useListImagesQuery } from 'services/api/endpoints/images'; * Registers gallery hotkeys. This hook is a singleton. */ export const useGalleryHotkeys = () => { - useAssertSingleton('useGalleryHotkeys'); + // useAssertSingleton('useGalleryHotkeys'); const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination(); const selection = useAppSelector((s) => s.gallery.selection); const queryArgs = useAppSelector(selectListImagesQueryArgs); diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts index 87f0b6aba2..cb37c2c11c 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react'; import { adHocPostProcessingRequested } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppStore } from 'app/store/nanostores/store'; +import { useAppSelector } from 'app/store/storeHooks'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; import { @@ -24,11 +25,10 @@ import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata'; import type { ImageDTO } from 'services/api/types'; export const useImageActions = (imageDTO: ImageDTO) => { - const dispatch = useAppDispatch(); + const { dispatch, getState } = useAppStore(); const { t } = useTranslation(); const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId); const isStaging = useAppSelector(selectIsStaging); - const activeTabName = useAppSelector(selectActiveTab); const { metadata } = useDebouncedMetadata(imageDTO.image_name); const [hasMetadata, setHasMetadata] = useState(false); const [hasSeed, setHasSeed] = useState(false); @@ -82,18 +82,20 @@ export const useImageActions = (imageDTO: ImageDTO) => { if (!metadata) { return; } + const activeTabName = selectActiveTab(getState()); parseAndRecallAllMetadata(metadata, activeTabName === 'canvas', isStaging ? ['width', 'height'] : []); clearStylePreset(); - }, [metadata, activeTabName, isStaging, clearStylePreset]); + }, [metadata, getState, isStaging, clearStylePreset]); const remix = useCallback(() => { if (!metadata) { return; } + const activeTabName = selectActiveTab(getState()); // Recalls all metadata parameters except seed parseAndRecallAllMetadata(metadata, activeTabName === 'canvas', ['seed']); clearStylePreset(); - }, [activeTabName, metadata, clearStylePreset]); + }, [metadata, getState, clearStylePreset]); const recallSeed = useCallback(() => { if (!metadata) { diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx index 00dc4754d6..37475ad6aa 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx @@ -12,12 +12,10 @@ import ParamClipSkip from 'features/parameters/components/Advanced/ParamClipSkip import ParamT5EncoderModelSelect from 'features/parameters/components/Advanced/ParamT5EncoderModelSelect'; import ParamSeamlessXAxis from 'features/parameters/components/Seamless/ParamSeamlessXAxis'; import ParamSeamlessYAxis from 'features/parameters/components/Seamless/ParamSeamlessYAxis'; -import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed'; import ParamFLUXVAEModelSelect from 'features/parameters/components/VAEModel/ParamFLUXVAEModelSelect'; import ParamVAEModelSelect from 'features/parameters/components/VAEModel/ParamVAEModelSelect'; import ParamVAEPrecision from 'features/parameters/components/VAEModel/ParamVAEPrecision'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetModelConfigQuery } from 'services/api/endpoints/models'; @@ -33,7 +31,6 @@ const formLabelProps2: FormLabelProps = { export const AdvancedSettingsAccordion = memo(() => { const vaeKey = useAppSelector(selectVAEKey); const { currentData: vaeConfig } = useGetModelConfigQuery(vaeKey ?? skipToken); - const activeTabName = useAppSelector(selectActiveTab); const isFLUX = useAppSelector(selectIsFLUX); const isSD3 = useAppSelector(selectIsSD3); @@ -68,19 +65,16 @@ export const AdvancedSettingsAccordion = memo(() => { if (params.seamlessXAxis || params.seamlessYAxis) { badges.push('seamless'); } - if (activeTabName === 'upscaling' && !params.shouldRandomizeSeed) { - badges.push('Manual Seed'); - } } return badges; }), - [vaeConfig, activeTabName] + [vaeConfig] ); const badges = useAppSelector(selectBadges); const { t } = useTranslation(); const { isOpen, onToggle } = useStandaloneAccordionToggle({ - id: `'advanced-settings-${activeTabName}`, + id: `'advanced-settings-generate`, defaultIsOpen: false, }); @@ -91,39 +85,33 @@ export const AdvancedSettingsAccordion = memo(() => { {isFLUX ? : } {!isFLUX && !isSD3 && } - {activeTabName === 'upscaling' ? ( - - ) : ( + {!isFLUX && !isSD3 && ( <> - {!isFLUX && !isSD3 && ( - <> - - - - - - - - - - - - )} - {isFLUX && ( - - - + + + + + + + + - )} - {isSD3 && ( - - - - - - )} + )} + {isFLUX && ( + + + + + )} + {isSD3 && ( + + + + + + )} ); diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/UpscaleTabAdvancedSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/UpscaleTabAdvancedSettingsAccordion.tsx new file mode 100644 index 0000000000..04b55e80c6 --- /dev/null +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/UpscaleTabAdvancedSettingsAccordion.tsx @@ -0,0 +1,90 @@ +import type { FormLabelProps } from '@invoke-ai/ui-library'; +import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectIsFLUX, selectIsSD3, selectParamsSlice, selectVAEKey } from 'features/controlLayers/store/paramsSlice'; +import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed'; +import ParamFLUXVAEModelSelect from 'features/parameters/components/VAEModel/ParamFLUXVAEModelSelect'; +import ParamVAEModelSelect from 'features/parameters/components/VAEModel/ParamVAEModelSelect'; +import ParamVAEPrecision from 'features/parameters/components/VAEModel/ParamVAEPrecision'; +import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGetModelConfigQuery } from 'services/api/endpoints/models'; + +const formLabelProps: FormLabelProps = { + minW: '9.2rem', +}; + +const formLabelProps2: FormLabelProps = { + flexGrow: 1, +}; + +export const AdvancedSettingsAccordion = memo(() => { + const vaeKey = useAppSelector(selectVAEKey); + const { currentData: vaeConfig } = useGetModelConfigQuery(vaeKey ?? skipToken); + const isFLUX = useAppSelector(selectIsFLUX); + const isSD3 = useAppSelector(selectIsSD3); + + const selectBadges = useMemo( + () => + createMemoizedSelector([selectParamsSlice, selectIsFLUX], (params, isFLUX) => { + const badges: (string | number)[] = []; + if (isFLUX) { + if (vaeConfig) { + let vaeBadge = vaeConfig.name; + if (params.vaePrecision === 'fp16') { + vaeBadge += ` ${params.vaePrecision}`; + } + badges.push(vaeBadge); + } + } else { + if (vaeConfig) { + let vaeBadge = vaeConfig.name; + if (params.vaePrecision === 'fp16') { + vaeBadge += ` ${params.vaePrecision}`; + } + badges.push(vaeBadge); + } else if (params.vaePrecision === 'fp16') { + badges.push(`VAE ${params.vaePrecision}`); + } + if (params.clipSkip) { + badges.push(`Skip ${params.clipSkip}`); + } + if (params.cfgRescaleMultiplier) { + badges.push(`Rescale ${params.cfgRescaleMultiplier}`); + } + if (params.seamlessXAxis || params.seamlessYAxis) { + badges.push('seamless'); + } + if (!params.shouldRandomizeSeed) { + badges.push('Manual Seed'); + } + } + + return badges; + }), + [vaeConfig] + ); + const badges = useAppSelector(selectBadges); + const { t } = useTranslation(); + const { isOpen, onToggle } = useStandaloneAccordionToggle({ + id: `'advanced-settings-upscaling`, + defaultIsOpen: false, + }); + + return ( + + + + {isFLUX ? : } + {!isFLUX && !isSD3 && } + + + + + ); +}); + +AdvancedSettingsAccordion.displayName = 'AdvancedSettingsAccordion'; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx index 0be0a0c602..1e5e29884b 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx @@ -12,14 +12,11 @@ import ParamGuidance from 'features/parameters/components/Core/ParamGuidance'; import ParamScheduler from 'features/parameters/components/Core/ParamScheduler'; import ParamSteps from 'features/parameters/components/Core/ParamSteps'; import { DisabledModelWarning } from 'features/parameters/components/MainModel/DisabledModelWarning'; -import ParamUpscaleCFGScale from 'features/parameters/components/Upscale/ParamUpscaleCFGScale'; -import ParamUpscaleScheduler from 'features/parameters/components/Upscale/ParamUpscaleScheduler'; import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel'; import { API_BASE_MODELS } from 'features/parameters/types/constants'; import { MainModelPicker } from 'features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker'; import { useExpanderToggle } from 'features/settingsAccordions/hooks/useExpanderToggle'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelectedModelConfig } from 'services/api/hooks/useSelectedModelConfig'; @@ -32,16 +29,12 @@ const formLabelProps: FormLabelProps = { export const GenerationSettingsAccordion = memo(() => { const { t } = useTranslation(); const modelConfig = useSelectedModelConfig(); - const activeTabName = useAppSelector(selectActiveTab); const isFLUX = useAppSelector(selectIsFLUX); const isSD3 = useAppSelector(selectIsSD3); const isCogView4 = useAppSelector(selectIsCogView4); const isApiModel = useIsApiModel(); - const isUpscaling = useMemo(() => { - return activeTabName === 'upscaling'; - }, [activeTabName]); const selectBadges = useMemo( () => createMemoizedSelector(selectLoRAsSlice, (loras) => { @@ -63,8 +56,8 @@ export const GenerationSettingsAccordion = memo(() => { defaultIsOpen: false, }); const { isOpen: isOpenAccordion, onToggle: onToggleAccordion } = useStandaloneAccordionToggle({ - id: `generation-settings-${activeTabName}`, - defaultIsOpen: activeTabName !== 'upscaling', + id: `generation-settings-generate`, + defaultIsOpen: true, }); return ( @@ -85,12 +78,10 @@ export const GenerationSettingsAccordion = memo(() => { - {!isFLUX && !isSD3 && !isCogView4 && !isUpscaling && } - {isUpscaling && } + {!isFLUX && !isSD3 && !isCogView4 && } {isFLUX && modelConfig && !isFluxFillMainModelModelConfig(modelConfig) && } - {isUpscaling && } - {!isFLUX && !isUpscaling && } + {!isFLUX && } diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/UpscaleTabGenerationSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/UpscaleTabGenerationSettingsAccordion.tsx new file mode 100644 index 0000000000..31221105e1 --- /dev/null +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/UpscaleTabGenerationSettingsAccordion.tsx @@ -0,0 +1,92 @@ +import type { FormLabelProps } from '@invoke-ai/ui-library'; +import { Box, Expander, Flex, FormControlGroup, StandaloneAccordion } from '@invoke-ai/ui-library'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice'; +import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; +import { LoRAList } from 'features/lora/components/LoRAList'; +import LoRASelect from 'features/lora/components/LoRASelect'; +import ParamGuidance from 'features/parameters/components/Core/ParamGuidance'; +import ParamSteps from 'features/parameters/components/Core/ParamSteps'; +import { DisabledModelWarning } from 'features/parameters/components/MainModel/DisabledModelWarning'; +import ParamUpscaleCFGScale from 'features/parameters/components/Upscale/ParamUpscaleCFGScale'; +import ParamUpscaleScheduler from 'features/parameters/components/Upscale/ParamUpscaleScheduler'; +import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel'; +import { API_BASE_MODELS } from 'features/parameters/types/constants'; +import { MainModelPicker } from 'features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker'; +import { useExpanderToggle } from 'features/settingsAccordions/hooks/useExpanderToggle'; +import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelectedModelConfig } from 'services/api/hooks/useSelectedModelConfig'; +import { isFluxFillMainModelModelConfig } from 'services/api/types'; + +const formLabelProps: FormLabelProps = { + minW: '4rem', +}; + +export const UpscaleTabGenerationSettingsAccordion = memo(() => { + const { t } = useTranslation(); + const modelConfig = useSelectedModelConfig(); + const isFLUX = useAppSelector(selectIsFLUX); + + const isApiModel = useIsApiModel(); + + const selectBadges = useMemo( + () => + createMemoizedSelector(selectLoRAsSlice, (loras) => { + const enabledLoRAsCount = loras.loras.filter((l) => l.isEnabled).length; + const loraTabBadges = enabledLoRAsCount ? [`${enabledLoRAsCount} ${t('models.concepts')}`] : EMPTY_ARRAY; + const accordionBadges = + modelConfig && API_BASE_MODELS.includes(modelConfig.base) + ? [modelConfig.name] + : modelConfig + ? [modelConfig.name, modelConfig.base] + : EMPTY_ARRAY; + return { loraTabBadges, accordionBadges }; + }), + [modelConfig, t] + ); + const { loraTabBadges, accordionBadges } = useAppSelector(selectBadges); + const { isOpen: isOpenExpander, onToggle: onToggleExpander } = useExpanderToggle({ + id: 'generation-settings-advanced', + defaultIsOpen: false, + }); + const { isOpen: isOpenAccordion, onToggle: onToggleAccordion } = useStandaloneAccordionToggle({ + id: `generation-settings-upscaling`, + defaultIsOpen: false, + }); + + return ( + + + + + + {!isApiModel && } + {!isApiModel && } + + {!isApiModel && ( + + + + + + {isFLUX && modelConfig && !isFluxFillMainModelModelConfig(modelConfig) && } + + + + + )} + + + ); +}); + +UpscaleTabGenerationSettingsAccordion.displayName = 'UpscaleTabGenerationSettingsAccordion'; diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index 199e5d8c43..74bcdc6119 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -1,15 +1,16 @@ -import { Flex } from '@invoke-ai/ui-library'; +import 'dockview/dist/styles/dockview.css'; +import 'features/ui/styles/dockview-theme-invoke.css'; + +import { TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { useDndMonitor } from 'features/dnd/useDndMonitor'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; -import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; -import { LeftPanelContent } from 'features/ui/components/LeftPanelContent'; -import { MainPanelContent } from 'features/ui/components/MainPanelContent'; -import { RightPanelContent } from 'features/ui/components/RightPanelContent'; import { VerticalNavBar } from 'features/ui/components/VerticalNavBar'; import type { UsePanelOptions } from 'features/ui/hooks/usePanel'; import { usePanel } from 'features/ui/hooks/usePanel'; +import { CanvasTabAutoLayout } from 'features/ui/layouts/canvas-tab-auto-layout'; +import { GenerateTabAutoLayout } from 'features/ui/layouts/generate-tab-auto-layout'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { $isLeftPanelOpen, $isRightPanelOpen, @@ -21,9 +22,6 @@ import { import type { CSSProperties } from 'react'; import { memo, useMemo, useRef } from 'react'; import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; -import { Panel, PanelGroup } from 'react-resizable-panels'; - -import { VerticalResizeHandle } from './tabs/ResizeHandle'; const panelStyles: CSSProperties = { position: 'relative', height: '100%', width: '100%', minWidth: 0 }; @@ -31,6 +29,7 @@ const onLeftPanelCollapse = (isCollapsed: boolean) => $isLeftPanelOpen.set(!isCo const onRightPanelCollapse = (isCollapsed: boolean) => $isRightPanelOpen.set(!isCollapsed); export const AppContent = memo(() => { + const tab = useAppSelector(selectActiveTab); const imperativePanelGroupRef = useRef(null); useDndMonitor(); @@ -108,38 +107,19 @@ export const AppContent = memo(() => { }); return ( - - - - {withLeftPanel && ( - <> - - - - - - )} - - - {withLeftPanel && } - {withRightPanel && } - - {withRightPanel && ( - <> - - - - - - )} - - + + + + + + + + + + + + + ); }); AppContent.displayName = 'AppContent'; diff --git a/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx index a603d540e7..e30de8bf17 100644 --- a/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx @@ -12,7 +12,7 @@ export const LeftPanelContent = memo(() => { const tab = useAppSelector(selectActiveTab); return ( - + {tab === 'generate' && } diff --git a/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx index 58010ba503..ec5e41deff 100644 --- a/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx @@ -1,27 +1,22 @@ import { useAppSelector } from 'app/store/storeHooks'; import { AdvancedSession } from 'features/controlLayers/components/AdvancedSession/AdvancedSession'; import { SimpleSession } from 'features/controlLayers/components/SimpleSession/SimpleSession'; -import { selectCanvasSessionId, selectGenerateSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab'; import QueueTab from 'features/ui/components/tabs/QueueTab'; import { WorkflowsMainPanel } from 'features/ui/components/tabs/WorkflowsTabContent'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { atom } from 'nanostores'; import { memo } from 'react'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; -export const $simpleId = atom(null); -export const $advancedId = atom(null); - export const MainPanelContent = memo(() => { const tab = useAppSelector(selectActiveTab); - const generateId = useAppSelector(selectGenerateSessionId); const canvasId = useAppSelector(selectCanvasSessionId); if (tab === 'generate') { - return ; + return ; } if (tab === 'canvas') { return ; diff --git a/invokeai/frontend/web/src/features/ui/components/TabButton.tsx b/invokeai/frontend/web/src/features/ui/components/TabButton.tsx index 8974a9c326..1a8850f309 100644 --- a/invokeai/frontend/web/src/features/ui/components/TabButton.tsx +++ b/invokeai/frontend/web/src/features/ui/components/TabButton.tsx @@ -1,5 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { IconButton, Tab, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; @@ -25,7 +25,8 @@ export const TabButton = memo(({ tab, icon, label }: { tab: TabName; icon: React return ( - { const customNavComponent = useStore($customNavComponent); return ( - + diff --git a/invokeai/frontend/web/src/features/ui/layouts/AutoLayout.tsx b/invokeai/frontend/web/src/features/ui/layouts/AutoLayout.tsx new file mode 100644 index 0000000000..a1feebb13f --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/AutoLayout.tsx @@ -0,0 +1,45 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import type { GridviewApi, IGridviewReactProps } from 'dockview'; +import { GridviewReact, Orientation } from 'dockview'; +import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context'; +import { canvasTabComponents, initializeCanvasTabLayout } from 'features/ui/layouts/canvas-tab-auto-layout'; +import { generateTabComponents, initializeGenerateTabLayout } from 'features/ui/layouts/generate-tab-auto-layout'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; +import type { TabName } from 'features/ui/store/uiTypes'; +import { memo, useCallback, useEffect, useState } from 'react'; + +const components: IGridviewReactProps['components'] = { + ...generateTabComponents, + ...canvasTabComponents, +}; + +export const AutoLayout = memo(() => { + const tab = useAppSelector(selectActiveTab); + const [api, setApi] = useState(null); + const syncLayout = useCallback((tab: TabName, api: GridviewApi) => { + if (tab === 'generate') { + initializeGenerateTabLayout(api); + } else if (tab === 'canvas') { + initializeCanvasTabLayout(api); + } + }, []); + const onReady = useCallback((event) => { + setApi(event.api); + }, []); + useEffect(() => { + if (api) { + syncLayout(tab, api); + } + }, [api, syncLayout, tab]); + return ( + + + + ); +}); +AutoLayout.displayName = 'AutoLayout'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx new file mode 100644 index 0000000000..77e469a462 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx @@ -0,0 +1,35 @@ +import { Flex, Text } from '@invoke-ai/ui-library'; +import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; +import type { IDockviewPanelHeaderProps } from 'dockview'; +import { useCallback, useEffect, useId, useRef } from 'react'; + +export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { + const id = useId(); + const ref = useRef(null); + const setActive = useCallback(() => { + if (!props.api.isActive) { + props.api.setActive(); + } + }, [props.api]); + + useCallbackOnDragEnter(setActive, ref, 300); + + useEffect(() => { + const el = document.querySelector(`[data-id="${id}"]`); + if (!el) { + return; + } + const parentTab = el.closest('.dv-tab'); + if (!parentTab) { + return; + } + parentTab.setAttribute('draggable', 'false'); + }, [id]); + + return ( + + {props.api.title ?? props.api.id} + + ); +}; +TabWithoutCloseButton.displayName = 'TabWithoutCloseButton'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx b/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx new file mode 100644 index 0000000000..d86ae4bee1 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx @@ -0,0 +1,14 @@ +import type { GridviewApi } from 'dockview'; +import type { PropsWithChildren } from 'react'; +import { createContext, useContext } from 'react'; + +const AutoLayoutContext = createContext(null); + +export const AutoLayoutProvider = (props: PropsWithChildren<{ api: GridviewApi | null }>) => { + return {props.children}; +}; + +export const useAutoLayoutContext = () => { + const api = useContext(AutoLayoutContext); + return api; +}; 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 new file mode 100644 index 0000000000..43ea4dc4e1 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/canvas-tab-auto-layout.tsx @@ -0,0 +1,339 @@ +import { Box, ContextMenu, Divider, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; +import { DockviewReact, GridviewReact, Orientation } from 'dockview'; +import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; +import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; +import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; +import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems'; +import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems'; +import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea'; +import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; +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 { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; +import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; +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 { BoardsListPanelContent } from 'features/gallery/components/BoardsListPanelContent'; +import { Gallery } from 'features/gallery/components/Gallery'; +import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; +import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; +import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; +import QueueControls from 'features/queue/components/QueueControls'; +import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; +import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context'; +import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; +import { LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_MIN_SIZE_PX } from 'features/ui/store/uiSlice'; +import { dockviewTheme } from 'features/ui/styles/theme'; +import { memo, useCallback, useState } from 'react'; +import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; + +const MenuContent = memo(() => { + return ( + + + + + + + ); +}); +MenuContent.displayName = 'MenuContent'; + +const canvasBgSx = { + position: 'relative', + w: 'full', + h: 'full', + borderRadius: 'base', + overflow: 'hidden', + bg: 'base.900', + '&[data-dynamic-grid="true"]': { + bg: 'base.850', + }, +}; + +export const CanvasPanel = memo(() => { + const dynamicGrid = useAppSelector(selectDynamicGrid); + const showHUD = useAppSelector(selectShowHUD); + const canvasId = useAppSelector(selectCanvasSessionId); + + const renderMenu = useCallback(() => { + return ; + }, []); + + return ( + + + + + + renderMenu={renderMenu} withLongPress={false}> + {(ref) => ( + + + + + {showHUD && } + + + + + + + } colorScheme="base" /> + + + + + + )} + + {canvasId !== null && ( + + + + + + + + + + + + + )} + + + + + + + + + + + + ); +}); +CanvasPanel.displayName = 'CanvasPanel'; + +const LayersPanelContent = memo(() => ( + + + +)); +LayersPanelContent.displayName = 'LayersPanelContent'; + +const ViewerPanelContent = memo(() => ( + + + + + +)); +ViewerPanelContent.displayName = 'ViewerPanelContent'; + +const ProgressPanelContent = memo(() => ( + + + +)); +ProgressPanelContent.displayName = 'ProgressPanelContent'; + +const mainPanelComponents: IDockviewReactProps['components'] = { + welcome: InitialState, + canvas: CanvasPanel, + viewer: ViewerPanelContent, + progress: ProgressPanelContent, +}; + +const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { + const { api } = event; + api.addPanel({ + id: 'welcome', + component: 'welcome', + title: 'Launchpad', + }); + api.addPanel({ + id: 'canvas', + component: 'canvas', + title: 'Canvas', + position: { + direction: 'within', + referencePanel: 'welcome', + }, + }); + api.addPanel({ + id: 'viewer', + component: 'viewer', + title: 'Image Viewer', + position: { + direction: 'within', + referencePanel: 'welcome', + }, + }); + api.addPanel({ + id: 'progress', + component: 'progress', + title: 'Generation Progress', + position: { + direction: 'within', + referencePanel: 'welcome', + }, + }); + + const disposables = [ + api.onWillShowOverlay((e) => { + if (e.kind === 'header_space' || e.kind === 'tab') { + return; + } + e.preventDefault(); + }), + ]; + + return () => { + disposables.forEach((disposable) => { + disposable.dispose(); + }); + }; +}; + +const MainPanel = memo(() => { + return ( + + + + ); +}); +MainPanel.displayName = 'MainPanel'; + +const Left = memo(() => { + return ( + + + + + + + ); +}); +Left.displayName = 'Left'; + +export const canvasTabComponents: IGridviewReactProps['components'] = { + left: Left, + main: MainPanel, + boards: BoardsListPanelContent, + gallery: Gallery, + layers: LayersPanelContent, +}; + +export const initializeCanvasTabLayout = (api: GridviewApi) => { + const main = api.addPanel({ + id: 'main', + component: 'main', + minimumWidth: 256, + }); + const left = api.addPanel({ + id: 'left', + component: 'left', + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + position: { + direction: 'left', + referencePanel: 'main', + }, + }); + api.addPanel({ + id: 'gallery', + component: 'gallery', + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: 232, + position: { + direction: 'right', + referencePanel: 'main', + }, + }); + api.addPanel({ + id: 'layers', + component: 'layers', + minimumHeight: 256, + position: { + direction: 'below', + referencePanel: 'gallery', + }, + }); + const boards = api.addPanel({ + id: 'boards', + component: 'boards', + minimumHeight: 36, + position: { + direction: 'above', + referencePanel: 'gallery', + }, + }); + left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); + boards.api.setSize({ height: 256, width: RIGHT_PANEL_MIN_SIZE_PX }); +}; + +export const CanvasTabAutoLayout = memo(() => { + const [api, setApi] = useState(null); + const onReady = useCallback((event) => { + setApi(event.api); + initializeCanvasTabLayout(event.api); + }, []); + return ( + + + + ); +}); +CanvasTabAutoLayout.displayName = 'CanvasTabAutoLayout'; 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 new file mode 100644 index 0000000000..f97a404652 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/generate-tab-auto-layout.tsx @@ -0,0 +1,175 @@ +import { Box, Divider, Flex } from '@invoke-ai/ui-library'; +import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; +import { DockviewReact, GridviewReact, Orientation } from 'dockview'; +import { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { BoardsListPanelContent } from 'features/gallery/components/BoardsListPanelContent'; +import { Gallery } from 'features/gallery/components/Gallery'; +import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; +import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; +import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; +import QueueControls from 'features/queue/components/QueueControls'; +import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; +import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context'; +import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; +import { LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_MIN_SIZE_PX } from 'features/ui/store/uiSlice'; +import { dockviewTheme } from 'features/ui/styles/theme'; +import { memo, useCallback, useState } from 'react'; + +const ViewerPanelContent = memo(() => ( + + + + + +)); +ViewerPanelContent.displayName = 'ViewerPanelContent'; + +const ProgressPanelContent = memo(() => ( + + + +)); +ProgressPanelContent.displayName = 'ProgressPanelContent'; + +const mainPanelComponents: IDockviewReactProps['components'] = { + welcome: InitialState, + viewer: ViewerPanelContent, + progress: ProgressPanelContent, +}; + +const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { + const { api } = event; + api.addPanel({ + id: 'welcome', + component: 'welcome', + title: 'Launchpad', + }); + api.addPanel({ + id: 'viewer', + component: 'viewer', + title: 'Image Viewer', + position: { + direction: 'within', + referencePanel: 'welcome', + }, + }); + api.addPanel({ + id: 'progress', + component: 'progress', + title: 'Generation Progress', + position: { + direction: 'within', + referencePanel: 'welcome', + }, + }); + + const disposables = [ + api.onWillShowOverlay((e) => { + if (e.kind === 'header_space' || e.kind === 'tab') { + return; + } + e.preventDefault(); + }), + ]; + + return () => { + disposables.forEach((disposable) => { + disposable.dispose(); + }); + }; +}; + +const MainPanel = memo(() => { + return ( + + + + ); +}); +MainPanel.displayName = 'MainPanel'; + +const Left = memo(() => { + return ( + + + + + + + ); +}); +Left.displayName = 'Left'; + +export const generateTabComponents: IGridviewReactProps['components'] = { + left: Left, + main: MainPanel, + boards: BoardsListPanelContent, + gallery: Gallery, +}; + +export const initializeGenerateTabLayout = (api: GridviewApi) => { + const main = api.addPanel({ + id: 'main', + component: 'main', + minimumWidth: 256, + }); + const left = api.addPanel({ + id: 'left', + component: 'left', + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + position: { + direction: 'left', + referencePanel: 'main', + }, + }); + api.addPanel({ + id: 'gallery', + component: 'gallery', + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: 232, + position: { + direction: 'right', + referencePanel: 'main', + }, + }); + const boards = api.addPanel({ + id: 'boards', + component: 'boards', + minimumHeight: 36, + position: { + direction: 'above', + referencePanel: 'gallery', + }, + }); + left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); + boards.api.setSize({ height: 256, width: RIGHT_PANEL_MIN_SIZE_PX }); +}; + +export const GenerateTabAutoLayout = memo(() => { + const [api, setApi] = useState(null); + const onReady = useCallback((event) => { + console.log('GenerateTabAutoLayout onReady'); + setApi(event.api); + initializeGenerateTabLayout(event.api); + }, []); + return ( + + + + ); +}); +GenerateTabAutoLayout.displayName = 'GenerateTabAutoLayout'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts b/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts new file mode 100644 index 0000000000..7d6bd82989 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/use-collapsible-gridview-panel.ts @@ -0,0 +1,98 @@ +import type { GridviewApi, GridviewPanelApi, IGridviewPanel } from 'dockview'; +import { atom } from 'nanostores'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +const getIsCollapsed = ( + panel: IGridviewPanel, + orientation: 'vertical' | 'horizontal', + collapsedSize?: number +) => { + if (orientation === 'vertical') { + return panel.height <= (collapsedSize ?? panel.minimumHeight); + } + return panel.width <= (collapsedSize ?? panel.minimumWidth); +}; + +export const useCollapsibleGridviewPanel = ( + api: GridviewApi | null, + panelId: string, + orientation: 'horizontal' | 'vertical', + defaultSize: number, + collapsedSize?: number +) => { + const $isCollapsed = useState(() => atom(false))[0]; + const collapse = useCallback(() => { + if (!api) { + return; + } + const panel = api.getPanel(panelId); + if (!panel) { + return; + } + if (orientation === 'vertical') { + panel.api.setSize({ height: collapsedSize ?? panel.minimumHeight }); + } else { + panel.api.setSize({ width: collapsedSize ?? panel.minimumWidth }); + } + }, [api, collapsedSize, orientation, panelId]); + + const expand = useCallback(() => { + if (!api) { + return; + } + const panel = api.getPanel(panelId); + if (!panel) { + return; + } + if (orientation === 'vertical') { + panel.api.setSize({ height: defaultSize }); + } else { + panel.api.setSize({ width: defaultSize }); + } + }, [api, defaultSize, orientation, panelId]); + + const toggle = useCallback(() => { + if (!api) { + return; + } + const panel = api.getPanel(panelId); + if (!panel) { + return; + } + const isCollapsed = getIsCollapsed(panel, orientation, collapsedSize); + if (isCollapsed) { + expand(); + } else { + collapse(); + } + }, [api, panelId, orientation, collapsedSize, expand, collapse]); + + useEffect(() => { + if (!api) { + return; + } + const panel = api.getPanel(panelId); + if (!panel) { + return; + } + + const disposable = panel.api.onDidDimensionsChange(() => { + const isCollapsed = getIsCollapsed(panel, orientation, collapsedSize); + $isCollapsed.set(isCollapsed); + }); + + return () => { + disposable.dispose(); + }; + }, [$isCollapsed, api, collapsedSize, orientation, panelId]); + + return useMemo( + () => ({ + $isCollapsed, + expand, + collapse, + toggle, + }), + [$isCollapsed, collapse, expand, toggle] + ); +}; diff --git a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts index 3990c28aa2..4b11ca8d6a 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiSelectors.ts @@ -5,3 +5,5 @@ export const selectActiveTab = createSelector(selectUiSlice, (ui) => ui.activeTa export const selectShouldShowImageDetails = createSelector(selectUiSlice, (ui) => ui.shouldShowImageDetails); export const selectShouldShowProgressInViewer = createSelector(selectUiSlice, (ui) => ui.shouldShowProgressInViewer); export const selectActiveTabCanvasRightPanel = createSelector(selectUiSlice, (ui) => ui.activeTabCanvasRightPanel); +export const selectShowGenerateTabSplashScreen = createSelector(selectUiSlice, (ui) => ui.showGenerateTabSplashScreen); +export const selectShowCanvasTabSplashScreen = createSelector(selectUiSlice, (ui) => ui.showCanvasTabSplashScreen); diff --git a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts index c96be420bb..2637b58d89 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiSlice.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiSlice.ts @@ -123,11 +123,11 @@ export const uiPersistConfig: PersistConfig = { }; const TABS_WITH_LEFT_PANEL: TabName[] = ['canvas', 'upscaling', 'workflows', 'generate'] as const; -export const LEFT_PANEL_MIN_SIZE_PX = 400; +export const LEFT_PANEL_MIN_SIZE_PX = 420; export const $isLeftPanelOpen = atom(true); export const selectWithLeftPanel = createSelector(selectUiSlice, (ui) => TABS_WITH_LEFT_PANEL.includes(ui.activeTab)); const TABS_WITH_RIGHT_PANEL: TabName[] = ['canvas', 'upscaling', 'workflows', 'generate'] as const; -export const RIGHT_PANEL_MIN_SIZE_PX = 390; +export const RIGHT_PANEL_MIN_SIZE_PX = 420; export const $isRightPanelOpen = atom(true); export const selectWithRightPanel = createSelector(selectUiSlice, (ui) => TABS_WITH_RIGHT_PANEL.includes(ui.activeTab)); diff --git a/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css b/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css new file mode 100644 index 0000000000..06a1e99c58 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css @@ -0,0 +1,65 @@ +.dockview-theme-invoke { + --dv-paneview-active-outline-color: var(--invoke-colors-invokeBlue-300); + --dv-tabs-and-actions-container-font-size: var(--invoke-fontSizes-sm); + --dv-tabs-and-actions-container-height: var(--invoke-sizes-8); + --dv-drag-over-background-color: var(--invoke-colors-baseAlpha-400); + --dv-drag-over-border-color: var(--invoke-colors-base-300); + --dv-tabs-container-scrollbar-color: #888; + --dv-icon-hover-background-color: rgba(90, 93, 94, 0.31); + --dv-floating-box-shadow: none; + --dv-overlay-z-index: 999; + + --dv-tab-font-size: inherit; + --dv-border-radius: 0; + --dv-tab-margin: 0; + --dv-sash-color: transparent; + --dv-active-sash-color: var(--invoke-colors-base-700); + --dv-active-sash-transition-duration: 0.15s; + --dv-active-sash-transition-delay: 0.1s; + + --dv-group-view-background-color: var(--invoke-colors-base-900); + + --dv-tabs-and-actions-container-background-color: var(--invoke-colors-base-850); + + --dv-activegroup-visiblepanel-tab-color: var(--invoke-colors-base-50); + --dv-activegroup-visiblepanel-tab-background-color: var(--invoke-colors-base-700); + + --dv-activegroup-hiddenpanel-tab-color: var(--invoke-colors-base-300); + --dv-activegroup-hiddenpanel-tab-background-color: var(--invoke-colors-base-850); + + --dv-inactivegroup-visiblepanel-tab-color: var(--invoke-colors-base-500); + --dv-inactivegroup-visiblepanel-tab-background-color: var(--invoke-colors-base-800); + + --dv-inactivegroup-hiddenpanel-tab-color: var(--invoke-colors-base-600); + --dv-inactivegroup-hiddenpanel-tab-background-color: var(--invoke-colors-base-850); + + --dv-tab-divider-color: var(--invoke-colors-base-700); + --dv-inactivegroup-tab-divider-color: var(--invoke-colors-base-800); + + --dv-separator-border: var(--invoke-colors-base-750); + --dv-paneview-header-border-color: rgba(204, 204, 204, 0.2); +} + +.dv-default-tab-content { + margin-right: 0px !important; +} + +.dv-groupview-floating { + border-radius: var(--invoke-space-2); + border-width: 1px; + border-color: var(--invoke-colors-base-800); + filter: drop-shadow(0px 0px 3px rgba(0, 0, 0, 0.4)) drop-shadow(5px 5px 10px rgba(0, 0, 0, 0.6)); +} + +.dv-resize-container { + border: none; +} + +.dv-tab { + /* margin-right: 2px; */ +} + +.dv-inactive-group .dv-tabs-container.dv-horizontal .dv-tab:not(:first-child)::before { + /* this is the tab divider */ + background-color: var(--dv-inactivegroup-tab-divider-color); +} diff --git a/invokeai/frontend/web/src/features/ui/styles/theme.ts b/invokeai/frontend/web/src/features/ui/styles/theme.ts new file mode 100644 index 0000000000..032bc48e34 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/styles/theme.ts @@ -0,0 +1,6 @@ +import type { DockviewTheme } from 'dockview'; + +export const dockviewTheme: DockviewTheme = { + name: 'invoke', + className: 'dockview-theme-invoke', +}; From 7f44da49020cba2f7e767bd0207525334b972f1b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:28:48 +1000 Subject: [PATCH 111/210] fix(ui): wonky stage sizing on first visibility --- .../controlLayers/konva/CanvasStageModule.ts | 84 +++++++++++-------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts index 9015017c22..ec9dd69faf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStageModule.ts @@ -154,36 +154,36 @@ export class CanvasStageModule extends CanvasModuleBase { // If the stage _had_ no size just before this function was called, that means we've just mounted the stage or // maybe un-hidden it. In that case, the user is about to see the stage for the first time, so we should fit the // layers to the stage. If we don't do this, the layers will not be centered. - const shouldFitLayersAfterFittingStage = this.konva.stage.width() === 0 || this.konva.stage.height() === 0; + if (this.konva.stage.width() === 0 || this.konva.stage.height() === 0) { + // This fit must happen before the stage size is set, else we can end up with a brief flash of an incorrectly + // sized and scaled stage. + this.fitLayersToStage({ animate: false, targetWidth: containerWidth, targetHeight: containerHeight }); + } this.konva.stage.width(containerWidth); this.konva.stage.height(containerHeight); this.syncStageAttrs(); - - if (shouldFitLayersAfterFittingStage) { - this.fitLayersToStage(); - } }; /** * Fits the bbox to the stage. This will center the bbox and scale it to fit the stage with some padding. */ - fitBboxToStage = (): void => { + fitBboxToStage = (options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => { const { rect } = this.manager.stateApi.getBbox(); this.log.trace({ rect }, 'Fitting bbox to stage'); - this.fitRect(rect); + this.fitRect(rect, options); }; /** * Fits the visible canvas to the stage. This will center the canvas and scale it to fit the stage with some padding. */ - fitLayersToStage = (): void => { + fitLayersToStage = (options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => { const rect = this.manager.compositor.getVisibleRectOfType(); if (rect.width === 0 || rect.height === 0) { - this.fitBboxToStage(); + this.fitBboxToStage(options); } else { this.log.trace({ rect }, 'Fitting layers to stage'); - this.fitRect(rect); + this.fitRect(rect, options); } }; @@ -191,12 +191,12 @@ export class CanvasStageModule extends CanvasModuleBase { * Fits the bbox and layers to the stage. The union of the bbox and the visible layers will be centered and scaled * to fit the stage with some padding. */ - fitBboxAndLayersToStage = (): void => { + fitBboxAndLayersToStage = (options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => { const layersRect = this.manager.compositor.getVisibleRectOfType(); const bboxRect = this.manager.stateApi.getBbox().rect; const unionRect = getRectUnion(layersRect, bboxRect); this.log.trace({ bboxRect, layersRect, unionRect }, 'Fitting bbox and layers to stage'); - this.fitRect(unionRect); + this.fitRect(unionRect, options); }; /** @@ -204,16 +204,22 @@ export class CanvasStageModule extends CanvasModuleBase { * * The max scale is 1, but the stage can be scaled down to fit the rect. */ - fitRect = (rect: Rect): void => { - const { width, height } = this.getSize(); + fitRect = (rect: Rect, options?: { animate?: boolean; targetWidth?: number; targetHeight?: number }): void => { + const size = this.getSize(); + const { animate, targetWidth, targetHeight } = { + animate: true, + targetWidth: size.width, + targetHeight: size.height, + ...options, + }; // If the stage has no size, we can't fit anything to it - if (width === 0 || height === 0) { + if (targetWidth === 0 || targetHeight === 0) { return; } - const availableWidth = width - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2; - const availableHeight = height - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2; + const availableWidth = targetWidth - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2; + const availableHeight = targetHeight - this.config.FIT_LAYERS_TO_STAGE_PADDING_PX * 2; // Make sure we don't accidentally set the scale to something nonsensical, like a negative number, 0 or something // outside the valid range @@ -231,23 +237,33 @@ export class CanvasStageModule extends CanvasModuleBase { this._intendedScale = scale; this._activeSnapPoint = null; - const tween = new Konva.Tween({ - node: this.konva.stage, - duration: 0.15, - x, - y, - scaleX: scale, - scaleY: scale, - easing: Konva.Easings.EaseInOut, - onUpdate: () => { - this.syncStageAttrs(); - }, - onFinish: () => { - this.syncStageAttrs(); - tween.destroy(); - }, - }); - tween.play(); + if (animate) { + const tween = new Konva.Tween({ + node: this.konva.stage, + duration: 0.15, + x, + y, + scaleX: scale, + scaleY: scale, + easing: Konva.Easings.EaseInOut, + onUpdate: () => { + this.syncStageAttrs(); + }, + onFinish: () => { + this.syncStageAttrs(); + tween.destroy(); + }, + }); + tween.play(); + } else { + this.konva.stage.setAttrs({ + x, + y, + scaleX: scale, + scaleY: scale, + }); + this.syncStageAttrs(); + } }; /** From 6eecdca56cdd912927b5368f819a3f4c39af467f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:42:42 +1000 Subject: [PATCH 112/210] wip --- .../frontend/web/src/app/components/App.tsx | 6 +- .../app/store/nanostores/globalIsLoading.ts | 13 + .../AdvancedSession/AdvancedSession.tsx | 4 +- .../components/CanvasLayersPanelContent.tsx | 17 +- .../components/SimpleSession/InitialState.tsx | 4 +- .../SimpleSession/SimpleSession.tsx | 4 +- .../components/BoardsListPanelContent.tsx | 4 +- .../features/gallery/components/Gallery.tsx | 4 +- .../gallery/components/GalleryTopBar.tsx | 5 +- .../ImageViewer/GenerationProgressPanel.tsx | 10 + .../ImageViewer/ImageViewerPanel.tsx | 13 + .../src/features/ui/components/AppContent.tsx | 183 +++++------ .../ui/components/RightPanelContent.tsx | 12 +- .../src/features/ui/components/TabButton.tsx | 5 +- .../src/features/ui/layouts/AutoLayout.tsx | 304 ++++++++++++++++-- .../ui/layouts/CanvasWorkspacePanel.tsx | 139 ++++++++ .../ui/layouts/TabWithoutCloseButton.tsx | 16 +- .../ui/layouts/auto-layout-context.tsx | 10 +- .../ui/layouts/canvas-tab-auto-layout.tsx | 42 +-- .../web/src/features/ui/layouts/components.ts | 29 ++ .../ui/layouts/generate-tab-auto-layout.tsx | 22 +- 21 files changed, 641 insertions(+), 205 deletions(-) create mode 100644 invokeai/frontend/web/src/app/store/nanostores/globalIsLoading.ts create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx create mode 100644 invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx create mode 100644 invokeai/frontend/web/src/features/ui/layouts/components.ts diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 96ce9888da..0cb811fec6 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'; import { GlobalHookIsolator } from 'app/components/GlobalHookIsolator'; import { GlobalModalIsolator } from 'app/components/GlobalModalIsolator'; import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; -import { $didStudioInit } from 'app/hooks/useStudioInitAction'; +import { $globalIsLoading } from 'app/store/nanostores/globalIsLoading'; import type { PartialAppConfig } from 'app/types/invokeai'; import Loading from 'common/components/Loading/Loading'; import { useClearStorage } from 'common/hooks/useClearStorage'; @@ -20,7 +20,7 @@ interface Props { } const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { - const didStudioInit = useStore($didStudioInit); + const globalIsLoading = useStore($globalIsLoading); const clearStorage = useClearStorage(); const handleReset = useCallback(() => { @@ -33,7 +33,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { - {!didStudioInit && } + {globalIsLoading && } diff --git a/invokeai/frontend/web/src/app/store/nanostores/globalIsLoading.ts b/invokeai/frontend/web/src/app/store/nanostores/globalIsLoading.ts new file mode 100644 index 0000000000..e875df4e52 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/globalIsLoading.ts @@ -0,0 +1,13 @@ +import { $didStudioInit } from 'app/hooks/useStudioInitAction'; +import { atom, computed } from 'nanostores'; +import { flushSync } from 'react-dom'; + +export const $isLayoutLoading = atom(false); +export const setIsLayoutLoading = (isLoading: boolean) => { + flushSync(() => { + $isLayoutLoading.set(isLoading); + }); +}; +export const $globalIsLoading = computed([$didStudioInit, $isLayoutLoading], (didStudioInit, isLayoutLoading) => { + return !didStudioInit || isLayoutLoading; +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx index 7a978ca905..fd21523c73 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx @@ -26,7 +26,7 @@ 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 { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/InitialState'; import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; @@ -84,7 +84,7 @@ export const AdvancedSession = memo(({ id }: { id: string | null }) => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx index 6de28bbfa5..3b28be2799 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx @@ -1,24 +1,19 @@ -import { Divider, Flex, type SystemStyleObject } from '@invoke-ai/ui-library'; +import { Divider, Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { CanvasAddEntityButtons } from 'features/controlLayers/components/CanvasAddEntityButtons'; import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityList'; import { EntityListSelectedEntityActionBar } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar'; +import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectHasEntities } from 'features/controlLayers/store/selectors'; import { memo } from 'react'; import { ParamDenoisingStrength } from './ParamDenoisingStrength'; -const FOCUS_REGION_STYLES: SystemStyleObject = { - width: 'full', - height: 'full', -}; - -export const CanvasLayersPanelContent = memo(() => { +export const CanvasLayersPanel = memo(() => { const hasEntities = useAppSelector(selectHasEntities); return ( - + @@ -27,8 +22,8 @@ export const CanvasLayersPanelContent = memo(() => { {!hasEntities && } {hasEntities && } - + ); }); -CanvasLayersPanelContent.displayName = 'CanvasLayersPanelContent'; +CanvasLayersPanel.displayName = 'CanvasLayersPanel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx index 73d867cff2..16c6510417 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx @@ -6,7 +6,7 @@ import { InitialStateMainModelPicker } from 'features/controlLayers/components/S import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; -export const InitialState = memo(() => { +export const GenerateLaunchpadPanel = memo(() => { const dispatch = useAppDispatch(); const newCanvasSession = useCallback(() => { dispatch(setActiveTab('canvas')); @@ -43,4 +43,4 @@ export const InitialState = memo(() => { ); }); -InitialState.displayName = 'InitialState'; +GenerateLaunchpadPanel.displayName = 'InitialState'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx index 54b0be8e1a..5d5dd3b79a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx @@ -1,6 +1,6 @@ import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/InitialState'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; @@ -25,7 +25,7 @@ export const SimpleSession = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx b/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx index bb80d8e160..edf5ca25ec 100644 --- a/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/BoardsListPanelContent.tsx @@ -8,7 +8,7 @@ import { memo } from 'react'; const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0 }; -export const BoardsListPanelContent = memo(() => { +export const BoardsPanel = memo(() => { const boardSearchDisclosure = useStore($boardSearchIsOpen); return ( @@ -23,4 +23,4 @@ export const BoardsListPanelContent = memo(() => { ); }); -BoardsListPanelContent.displayName = 'BoardsListPanelContent'; +BoardsPanel.displayName = 'BoardsPanel'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 0fcfa7dd82..1679cc1fb1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -46,7 +46,7 @@ const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '10 const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); const selectSearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); -export const Gallery = memo(() => { +export const GalleryPanel = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const galleryView = useAppSelector(selectGalleryView); @@ -117,4 +117,4 @@ export const Gallery = memo(() => { ); }); -Gallery.displayName = 'Gallery'; +GalleryPanel.displayName = 'Gallery'; diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx index a5ff519384..6a05236929 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx @@ -17,8 +17,9 @@ export const GalleryTopBar = memo(() => { const dispatch = useAppDispatch(); const boardSearchText = useAppSelector(selectBoardSearchText); const boardSearchDisclosure = useBoardSearchDisclosure(); - const api = useAutoLayoutContext(); - const boardsPanel = useCollapsibleGridviewPanel(api, 'boards', 'vertical', 256); + const $api = useAutoLayoutContext(); + const api = useStore($api); + const boardsPanel = useCollapsibleGridviewPanel(api, 'Boards', 'vertical', 256); const isBoardsPanelCollapsed = useStore(boardsPanel.$isCollapsed); const onClickBoardSearch = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx new file mode 100644 index 0000000000..bfcda19650 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx @@ -0,0 +1,10 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; +import { memo } from 'react'; + +export const GenerationProgressPanel = memo(() => ( + + + +)); +GenerationProgressPanel.displayName = 'GenerationProgressPanel'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx new file mode 100644 index 0000000000..0fd32cfa54 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx @@ -0,0 +1,13 @@ +import { Divider, Flex } from '@invoke-ai/ui-library'; +import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; +import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; +import { memo } from 'react'; + +export const ImageViewerPanel = memo(() => ( + + + + + +)); +ImageViewerPanel.displayName = 'ImageViewerPanel'; diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index 74bcdc6119..c66fdfb6c8 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -1,27 +1,13 @@ import 'dockview/dist/styles/dockview.css'; import 'features/ui/styles/dockview-theme-invoke.css'; -import { TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; +import { Flex } from '@invoke-ai/ui-library'; import { useDndMonitor } from 'features/dnd/useDndMonitor'; -import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { VerticalNavBar } from 'features/ui/components/VerticalNavBar'; -import type { UsePanelOptions } from 'features/ui/hooks/usePanel'; -import { usePanel } from 'features/ui/hooks/usePanel'; -import { CanvasTabAutoLayout } from 'features/ui/layouts/canvas-tab-auto-layout'; -import { GenerateTabAutoLayout } from 'features/ui/layouts/generate-tab-auto-layout'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { - $isLeftPanelOpen, - $isRightPanelOpen, - LEFT_PANEL_MIN_SIZE_PX, - RIGHT_PANEL_MIN_SIZE_PX, - selectWithLeftPanel, - selectWithRightPanel, -} from 'features/ui/store/uiSlice'; +import { AutoLayout } from 'features/ui/layouts/AutoLayout'; +import { $isLeftPanelOpen, $isRightPanelOpen } from 'features/ui/store/uiSlice'; import type { CSSProperties } from 'react'; -import { memo, useMemo, useRef } from 'react'; -import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; +import { memo } from 'react'; const panelStyles: CSSProperties = { position: 'relative', height: '100%', width: '100%', minWidth: 0 }; @@ -29,97 +15,88 @@ const onLeftPanelCollapse = (isCollapsed: boolean) => $isLeftPanelOpen.set(!isCo const onRightPanelCollapse = (isCollapsed: boolean) => $isRightPanelOpen.set(!isCollapsed); export const AppContent = memo(() => { - const tab = useAppSelector(selectActiveTab); - const imperativePanelGroupRef = useRef(null); + // const tab = useAppSelector(selectActiveTab); + // const imperativePanelGroupRef = useRef(null); useDndMonitor(); - const withLeftPanel = useAppSelector(selectWithLeftPanel); - const leftPanelUsePanelOptions = useMemo( - () => ({ - id: 'left-panel', - minSizePx: LEFT_PANEL_MIN_SIZE_PX, - defaultSizePx: LEFT_PANEL_MIN_SIZE_PX, - imperativePanelGroupRef, - panelGroupDirection: 'horizontal', - onCollapse: onLeftPanelCollapse, - }), - [] - ); - const leftPanel = usePanel(leftPanelUsePanelOptions); - useRegisteredHotkeys({ - id: 'toggleLeftPanel', - category: 'app', - callback: leftPanel.toggle, - options: { enabled: withLeftPanel }, - dependencies: [leftPanel.toggle, withLeftPanel], - }); + // const withLeftPanel = useAppSelector(selectWithLeftPanel); + // const leftPanelUsePanelOptions = useMemo( + // () => ({ + // id: 'left-panel', + // minSizePx: LEFT_PANEL_MIN_SIZE_PX, + // defaultSizePx: LEFT_PANEL_MIN_SIZE_PX, + // imperativePanelGroupRef, + // panelGroupDirection: 'horizontal', + // onCollapse: onLeftPanelCollapse, + // }), + // [] + // ); + // const leftPanel = usePanel(leftPanelUsePanelOptions); + // useRegisteredHotkeys({ + // id: 'toggleLeftPanel', + // category: 'app', + // callback: leftPanel.toggle, + // options: { enabled: withLeftPanel }, + // dependencies: [leftPanel.toggle, withLeftPanel], + // }); - const withRightPanel = useAppSelector(selectWithRightPanel); - const rightPanelUsePanelOptions = useMemo( - () => ({ - id: 'right-panel', - minSizePx: RIGHT_PANEL_MIN_SIZE_PX, - defaultSizePx: RIGHT_PANEL_MIN_SIZE_PX, - imperativePanelGroupRef, - panelGroupDirection: 'horizontal', - onCollapse: onRightPanelCollapse, - }), - [] - ); - const rightPanel = usePanel(rightPanelUsePanelOptions); - useRegisteredHotkeys({ - id: 'toggleRightPanel', - category: 'app', - callback: rightPanel.toggle, - options: { enabled: withRightPanel }, - dependencies: [rightPanel.toggle, withRightPanel], - }); + // const withRightPanel = useAppSelector(selectWithRightPanel); + // const rightPanelUsePanelOptions = useMemo( + // () => ({ + // id: 'right-panel', + // minSizePx: RIGHT_PANEL_MIN_SIZE_PX, + // defaultSizePx: RIGHT_PANEL_MIN_SIZE_PX, + // imperativePanelGroupRef, + // panelGroupDirection: 'horizontal', + // onCollapse: onRightPanelCollapse, + // }), + // [] + // ); + // const rightPanel = usePanel(rightPanelUsePanelOptions); + // useRegisteredHotkeys({ + // id: 'toggleRightPanel', + // category: 'app', + // callback: rightPanel.toggle, + // options: { enabled: withRightPanel }, + // dependencies: [rightPanel.toggle, withRightPanel], + // }); - useRegisteredHotkeys({ - id: 'resetPanelLayout', - category: 'app', - callback: () => { - leftPanel.reset(); - rightPanel.reset(); - }, - dependencies: [leftPanel.reset, rightPanel.reset], - }); - useRegisteredHotkeys({ - id: 'togglePanels', - category: 'app', - callback: () => { - if (leftPanel.isCollapsed || rightPanel.isCollapsed) { - leftPanel.expand(); - rightPanel.expand(); - } else { - leftPanel.collapse(); - rightPanel.collapse(); - } - }, - dependencies: [ - leftPanel.isCollapsed, - rightPanel.isCollapsed, - leftPanel.expand, - rightPanel.expand, - leftPanel.collapse, - rightPanel.collapse, - ], - }); + // useRegisteredHotkeys({ + // id: 'resetPanelLayout', + // category: 'app', + // callback: () => { + // leftPanel.reset(); + // rightPanel.reset(); + // }, + // dependencies: [leftPanel.reset, rightPanel.reset], + // }); + // useRegisteredHotkeys({ + // id: 'togglePanels', + // category: 'app', + // callback: () => { + // if (leftPanel.isCollapsed || rightPanel.isCollapsed) { + // leftPanel.expand(); + // rightPanel.expand(); + // } else { + // leftPanel.collapse(); + // rightPanel.collapse(); + // } + // }, + // dependencies: [ + // leftPanel.isCollapsed, + // rightPanel.isCollapsed, + // leftPanel.expand, + // rightPanel.expand, + // leftPanel.collapse, + // rightPanel.collapse, + // ], + // }); return ( - - - - - - - - - - - - - + + + + ); }); AppContent.displayName = 'AppContent'; diff --git a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx index 6625ce0175..04bf65154d 100644 --- a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx @@ -2,10 +2,10 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { useDisclosure } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; -import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; +import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { BoardsListPanelContent } from 'features/gallery/components/BoardsListPanelContent'; -import { Gallery } from 'features/gallery/components/Gallery'; +import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; +import { GalleryPanel } from 'features/gallery/components/Gallery'; import { GalleryTopBar } from 'features/gallery/components/GalleryTopBar'; import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle'; @@ -71,18 +71,18 @@ export const RightPanelContent = memo(() => { - + - + {tab === 'canvas' && ( <> - + diff --git a/invokeai/frontend/web/src/features/ui/components/TabButton.tsx b/invokeai/frontend/web/src/features/ui/components/TabButton.tsx index 1a8850f309..8974a9c326 100644 --- a/invokeai/frontend/web/src/features/ui/components/TabButton.tsx +++ b/invokeai/frontend/web/src/features/ui/components/TabButton.tsx @@ -1,5 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { IconButton, Tab, Tooltip } from '@invoke-ai/ui-library'; +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; @@ -25,8 +25,7 @@ export const TabButton = memo(({ tab, icon, label }: { tab: TabName; icon: React return ( - { + return ( + api.getPanel('GenerateLaunchpad') || + api.addPanel({ + id: 'GenerateLaunchpad', + component: 'GenerateLaunchpad', + title: 'Launchpad', + }) + ); +}; + +const getCanvasLaunchpadPanel = (api: DockviewApi) => { + return ( + api.getPanel('CanvasLaunchpad') || + api.addPanel({ + id: 'CanvasLaunchpad', + component: 'CanvasLaunchpad', + title: 'Launchpad', + }) + ); +}; + +const getCanvasWorkspacePanel = (api: DockviewApi) => { + return ( + api.getPanel('CanvasWorkspace') || + api.addPanel({ + id: 'CanvasWorkspace', + component: 'CanvasWorkspace', + title: 'Canvas', + }) + ); +}; + +const getImageViewerPanel = (api: DockviewApi) => { + return ( + api.getPanel('ImageViewer') || + api.addPanel({ + id: 'ImageViewer', + component: 'ImageViewer', + title: 'Image Viewer', + }) + ); +}; + +const getGenerationProgressPanel = (api: DockviewApi) => { + return ( + api.getPanel('GenerationProgress') || + api.addPanel({ + id: 'GenerationProgress', + component: 'GenerationProgress', + title: 'Generation Progress', + }) + ); +}; + +const syncMainPanelLayout = (tab: TabName, api: DockviewApi) => { + if (tab === 'generate') { + const GenerateLaunchpad = getGenerateLaunchpadPanel(api); + const ImageViewer = getImageViewerPanel(api); + const GenerationProgress = getGenerationProgressPanel(api); + const panelsToKeep = [GenerateLaunchpad.id, ImageViewer.id, GenerationProgress.id]; + for (const panel of api.panels) { + if (!panelsToKeep.includes(panel.id)) { + api.removePanel(panel); + } + } + } else if (tab === 'canvas') { + const CanvasLaunchpad = getCanvasLaunchpadPanel(api); + const CanvasWorkspace = getCanvasWorkspacePanel(api); + const ImageViewer = getImageViewerPanel(api); + const GenerationProgress = getGenerationProgressPanel(api); + const panelsToKeep = [CanvasLaunchpad.id, CanvasWorkspace.id, ImageViewer.id, GenerationProgress.id]; + for (const panel of api.panels) { + if (!panelsToKeep.includes(panel.id)) { + api.removePanel(panel); + } + } + } +}; + +const MainPanel = memo(() => { + const tab = useAppSelector(selectActiveTab); + const [api, setApi] = useState(null); + const onReady = useCallback((event) => { + console.log('MainPanel onReady', event.api); + setApi(event.api); + }, []); + + useEffect(() => { + if (api) { + syncMainPanelLayout(tab, api); + } + }, [api, tab]); + + return ( + + + + ); +}); +MainPanel.displayName = 'MainPanel'; + +export const gridviewComponents: IGridviewReactProps['components'] = { + // Shared components + Gallery: GalleryPanel, + Boards: BoardsPanel, + Main: MainPanel, + GenerateLeft: GenerateLeftPanel, + CanvasLeft: GenerateLeftPanel, + CanvasLayers: CanvasLayersPanel, +}; + +const syncGridviewLayout = (tab: TabName, api: GridviewApi) => { + if (tab === 'generate') { + const MainPanel = + api.getPanel('Main') ?? + api.addPanel({ + id: 'Main', + component: 'Main', + }); + + const GenerateLeftPanel = + api.getPanel('GenerateLeft') ?? + api.addPanel({ + id: 'GenerateLeft', + component: 'GenerateLeft', + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + position: { + direction: 'left', + referencePanel: MainPanel.id, + }, + }); + + const GalleryPanel = + api.getPanel('Gallery') ?? + api.addPanel({ + id: 'Gallery', + component: 'Gallery', + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: 232, + position: { + direction: 'right', + referencePanel: MainPanel.id, + }, + }); + + const BoardsPanel = + api.getPanel('Boards') ?? + api.addPanel({ + id: 'Boards', + component: 'Boards', + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: 36, + position: { + direction: 'above', + referencePanel: GalleryPanel.id, + }, + }); + + const panelsToKeep = [MainPanel.id, GenerateLeftPanel.id, GalleryPanel.id, BoardsPanel.id]; + for (const panel of api.panels) { + if (!panelsToKeep.includes(panel.id)) { + api.removePanel(panel); + } + } + } else if (tab === 'canvas') { + const MainPanel = + api.getPanel('Main') ?? + api.addPanel({ + id: 'Main', + component: 'Main', + }); + + const CanvasLeftPanel = + api.getPanel('CanvasLeft') ?? + api.addPanel({ + id: 'CanvasLeft', + component: 'CanvasLeft', + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + position: { + direction: 'left', + referencePanel: MainPanel.id, + }, + }); + + const GalleryPanel = + api.getPanel('Gallery') ?? + api.addPanel({ + id: 'Gallery', + component: 'Gallery', + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: 232, + position: { + direction: 'right', + referencePanel: MainPanel.id, + }, + }); + + const BoardsPanel = + api.getPanel('Boards') ?? + api.addPanel({ + id: 'Boards', + component: 'Boards', + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: 36, + position: { + direction: 'above', + referencePanel: GalleryPanel.id, + }, + }); + + const CanvasLayersPanel = + api.getPanel('CanvasLayers') ?? + api.addPanel({ + id: 'CanvasLayers', + component: 'CanvasLayers', + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: 232, + position: { + direction: 'below', + referencePanel: GalleryPanel.id, + }, + }); + + const panelsToKeep = [MainPanel.id, CanvasLeftPanel.id, GalleryPanel.id, BoardsPanel.id, CanvasLayersPanel.id]; + for (const panel of api.panels) { + if (!panelsToKeep.includes(panel.id)) { + api.removePanel(panel); + } + } + } }; export const AutoLayout = memo(() => { const tab = useAppSelector(selectActiveTab); - const [api, setApi] = useState(null); - const syncLayout = useCallback((tab: TabName, api: GridviewApi) => { - if (tab === 'generate') { - initializeGenerateTabLayout(api); - } else if (tab === 'canvas') { - initializeCanvasTabLayout(api); - } - }, []); - const onReady = useCallback((event) => { - setApi(event.api); - }, []); + const $api = useState(() => atom(null))[0]; + const onReady = useCallback( + (event) => { + $api.set(event.api); + }, + [$api] + ); useEffect(() => { + const api = $api.get(); if (api) { - syncLayout(tab, api); + syncGridviewLayout(tab, api); } - }, [api, syncLayout, tab]); + }, [$api, tab]); return ( - + diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx new file mode 100644 index 0000000000..1a2a592ddd --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx @@ -0,0 +1,139 @@ +import { ContextMenu, Divider, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; +import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; +import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; +import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems'; +import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems'; +import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea'; +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 { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; +import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; +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'; + +const MenuContent = memo(() => { + return ( + + + + + + + ); +}); +MenuContent.displayName = 'MenuContent'; + +const canvasBgSx = { + position: 'relative', + w: 'full', + h: 'full', + borderRadius: 'base', + overflow: 'hidden', + bg: 'base.900', + '&[data-dynamic-grid="true"]': { + bg: 'base.850', + }, +}; + +export const CanvasWorkspacePanel = memo(() => { + const dynamicGrid = useAppSelector(selectDynamicGrid); + const showHUD = useAppSelector(selectShowHUD); + const canvasId = useAppSelector(selectCanvasSessionId); + + const renderMenu = useCallback(() => { + return ; + }, []); + + return ( + + + + + + renderMenu={renderMenu} withLongPress={false}> + {(ref) => ( + + + + + {showHUD && } + + + + + + + } colorScheme="base" /> + + + + + + )} + + {canvasId !== null && ( + + + + + + + + + + + + + )} + + + + + + + + + + + + ); +}); +CanvasWorkspacePanel.displayName = 'CanvasPanel'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx index 77e469a462..0d4803adca 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx @@ -1,10 +1,9 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; import type { IDockviewPanelHeaderProps } from 'dockview'; -import { useCallback, useEffect, useId, useRef } from 'react'; +import { useCallback, useRef } from 'react'; export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { - const id = useId(); const ref = useRef(null); const setActive = useCallback(() => { if (!props.api.isActive) { @@ -13,18 +12,7 @@ export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { }, [props.api]); useCallbackOnDragEnter(setActive, ref, 300); - - useEffect(() => { - const el = document.querySelector(`[data-id="${id}"]`); - if (!el) { - return; - } - const parentTab = el.closest('.dv-tab'); - if (!parentTab) { - return; - } - parentTab.setAttribute('draggable', 'false'); - }, [id]); + console.log(props.api.title); return ( diff --git a/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx b/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx index d86ae4bee1..87ad53faf8 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx @@ -1,14 +1,18 @@ import type { GridviewApi } from 'dockview'; +import type { Atom } from 'nanostores'; import type { PropsWithChildren } from 'react'; import { createContext, useContext } from 'react'; -const AutoLayoutContext = createContext(null); +const AutoLayoutContext = createContext | null>(null); -export const AutoLayoutProvider = (props: PropsWithChildren<{ api: GridviewApi | null }>) => { - return {props.children}; +export const AutoLayoutProvider = (props: PropsWithChildren<{ $api: Atom }>) => { + return {props.children}; }; export const useAutoLayoutContext = () => { const api = useContext(AutoLayoutContext); + if (!api) { + throw new Error('useAutoLayoutContext must be used within an AutoLayoutProvider'); + } return api; }; 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 43ea4dc4e1..ed2cc14b8f 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,4 +1,5 @@ import { Box, ContextMenu, Divider, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { $isLayoutLoading } from 'app/store/nanostores/globalIsLoading'; import { useAppSelector } from 'app/store/storeHooks'; import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, Orientation } from 'dockview'; @@ -8,13 +9,13 @@ import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/compone import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems'; import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems'; import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea'; -import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; +import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; 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 { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/InitialState'; import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; @@ -22,14 +23,15 @@ 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 { BoardsListPanelContent } from 'features/gallery/components/BoardsListPanelContent'; -import { Gallery } from 'features/gallery/components/Gallery'; +import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; +import { GalleryPanel } from 'features/gallery/components/Gallery'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; import QueueControls from 'features/queue/components/QueueControls'; import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context'; +import { components } from 'features/ui/layouts/components'; import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; import { LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_MIN_SIZE_PX } from 'features/ui/store/uiSlice'; import { dockviewTheme } from 'features/ui/styles/theme'; @@ -60,7 +62,7 @@ const canvasBgSx = { }, }; -export const CanvasPanel = memo(() => { +export const CanvasWorkspacePanel = memo(() => { const dynamicGrid = useAppSelector(selectDynamicGrid); const showHUD = useAppSelector(selectShowHUD); const canvasId = useAppSelector(selectCanvasSessionId); @@ -151,11 +153,11 @@ export const CanvasPanel = memo(() => { ); }); -CanvasPanel.displayName = 'CanvasPanel'; +CanvasWorkspacePanel.displayName = 'CanvasPanel'; const LayersPanelContent = memo(() => ( - + )); LayersPanelContent.displayName = 'LayersPanelContent'; @@ -177,8 +179,8 @@ const ProgressPanelContent = memo(() => ( ProgressPanelContent.displayName = 'ProgressPanelContent'; const mainPanelComponents: IDockviewReactProps['components'] = { - welcome: InitialState, - canvas: CanvasPanel, + canvasLaunchpad: GenerateLaunchpadPanel, + canvas: CanvasWorkspacePanel, viewer: ViewerPanelContent, progress: ProgressPanelContent, }; @@ -186,9 +188,9 @@ const mainPanelComponents: IDockviewReactProps['components'] = { const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { const { api } = event; api.addPanel({ - id: 'welcome', - component: 'welcome', - title: 'Launchpad', + id: 'canvasLaunchpad', + component: 'canvasLaunchpad', + title: 'canvasLaunchpad', }); api.addPanel({ id: 'canvas', @@ -196,7 +198,7 @@ const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { title: 'Canvas', position: { direction: 'within', - referencePanel: 'welcome', + referencePanel: 'canvasLaunchpad', }, }); api.addPanel({ @@ -205,7 +207,7 @@ const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { title: 'Image Viewer', position: { direction: 'within', - referencePanel: 'welcome', + referencePanel: 'canvasLaunchpad', }, }); api.addPanel({ @@ -214,7 +216,7 @@ const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { title: 'Generation Progress', position: { direction: 'within', - referencePanel: 'welcome', + referencePanel: 'canvasLaunchpad', }, }); @@ -243,7 +245,7 @@ const MainPanel = memo(() => { disableFloatingGroups={true} dndEdges={false} defaultTabComponent={TabWithoutCloseButton} - components={mainPanelComponents} + components={components} onReady={onReadyMainPanel} theme={dockviewTheme} /> @@ -264,11 +266,13 @@ const Left = memo(() => { }); Left.displayName = 'Left'; +const Null = () => null; + export const canvasTabComponents: IGridviewReactProps['components'] = { left: Left, main: MainPanel, - boards: BoardsListPanelContent, - gallery: Gallery, + boards: BoardsPanel, + gallery: GalleryPanel, layers: LayersPanelContent, }; @@ -322,8 +326,10 @@ export const initializeCanvasTabLayout = (api: GridviewApi) => { export const CanvasTabAutoLayout = memo(() => { const [api, setApi] = useState(null); const onReady = useCallback((event) => { + $isLayoutLoading.set(true); setApi(event.api); initializeCanvasTabLayout(event.api); + $isLayoutLoading.set(false); }, []); return ( diff --git a/invokeai/frontend/web/src/features/ui/layouts/components.ts b/invokeai/frontend/web/src/features/ui/layouts/components.ts new file mode 100644 index 0000000000..becb52fc3b --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/components.ts @@ -0,0 +1,29 @@ +import type { IDockviewReactProps, IGridviewReactProps } from 'dockview'; +import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; +import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; +import { GalleryPanel } from 'features/gallery/components/Gallery'; +import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer/GenerationProgressPanel'; +import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; +import { CanvasWorkspacePanel } from 'features/ui/layouts/canvas-tab-auto-layout'; +import { GenerateLeftPanel } from 'features/ui/layouts/generate-tab-auto-layout'; + +export const components: IDockviewReactProps['components'] & IGridviewReactProps['components'] = { + // Shared components + Gallery: GalleryPanel, + Boards: BoardsPanel, + ImageViewer: ImageViewerPanel, + GenerationProgress: GenerationProgressPanel, + // Generate tab + GenerateLaunchpad: GenerateLaunchpadPanel, + GenerateLeft: GenerateLeftPanel, + // Upscaling tab + UpscalingLaunchpad: GenerateLaunchpadPanel, + // Workflows tab + WorkflowsLaunchpad: GenerateLaunchpadPanel, + // Canvas tab + CanvasLaunchpad: GenerateLaunchpadPanel, + CanvasLayers: CanvasLayersPanel, + CanvasWorkspace: CanvasWorkspacePanel, + CanvasLeft: GenerateLeftPanel, +}; 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 f97a404652..26ade5fe55 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,9 +1,10 @@ import { Box, Divider, Flex } from '@invoke-ai/ui-library'; +import { $isLayoutLoading } from 'app/store/nanostores/globalIsLoading'; import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; import { DockviewReact, GridviewReact, Orientation } from 'dockview'; -import { InitialState } from 'features/controlLayers/components/SimpleSession/InitialState'; -import { BoardsListPanelContent } from 'features/gallery/components/BoardsListPanelContent'; -import { Gallery } from 'features/gallery/components/Gallery'; +import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; +import { GalleryPanel } from 'features/gallery/components/Gallery'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; @@ -32,7 +33,7 @@ const ProgressPanelContent = memo(() => ( ProgressPanelContent.displayName = 'ProgressPanelContent'; const mainPanelComponents: IDockviewReactProps['components'] = { - welcome: InitialState, + welcome: GenerateLaunchpadPanel, viewer: ViewerPanelContent, progress: ProgressPanelContent, }; @@ -97,7 +98,7 @@ const MainPanel = memo(() => { }); MainPanel.displayName = 'MainPanel'; -const Left = memo(() => { +export const GenerateLeftPanel = memo(() => { return ( @@ -107,13 +108,13 @@ const Left = memo(() => { ); }); -Left.displayName = 'Left'; +GenerateLeftPanel.displayName = 'GenerateLeftPanel'; export const generateTabComponents: IGridviewReactProps['components'] = { - left: Left, + left: GenerateLeftPanel, main: MainPanel, - boards: BoardsListPanelContent, - gallery: Gallery, + boards: BoardsPanel, + gallery: GalleryPanel, }; export const initializeGenerateTabLayout = (api: GridviewApi) => { @@ -157,9 +158,10 @@ export const initializeGenerateTabLayout = (api: GridviewApi) => { export const GenerateTabAutoLayout = memo(() => { const [api, setApi] = useState(null); const onReady = useCallback((event) => { - console.log('GenerateTabAutoLayout onReady'); + $isLayoutLoading.set(true); setApi(event.api); initializeGenerateTabLayout(event.api); + $isLayoutLoading.set(false); }, []); return ( From fcaeba290eb95562158dc99a86945a84accc7d59 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 19 Jun 2025 19:28:45 +1000 Subject: [PATCH 113/210] feat(ui): canvas launchpad --- .../AdvancedSession/AdvancedSession.tsx | 2 +- .../SimpleSession/CanvasLaunchpadPanel.tsx | 36 +++++++++++++++++++ ...alState.tsx => GenerateLaunchpadPanel.tsx} | 11 +++--- ...nce.tsx => LaunchpadAddStyleReference.tsx} | 10 +++--- ...ButtonGridItem.tsx => LaunchpadButton.tsx} | 4 +-- ...eCard.tsx => LaunchpadEditImageButton.tsx} | 18 +++++----- ...sx => LaunchpadGenerateFromTextButton.tsx} | 10 +++--- ...tsx => LaunchpadUseALayoutImageButton.tsx} | 19 +++++----- .../SimpleSession/SimpleSession.tsx | 2 +- .../Toolbar/CanvasToolbarResetViewButton.tsx | 4 +-- .../src/features/ui/layouts/AutoLayout.tsx | 5 +-- .../ui/layouts/canvas-tab-auto-layout.tsx | 2 +- .../web/src/features/ui/layouts/components.ts | 2 +- .../ui/layouts/generate-tab-auto-layout.tsx | 2 +- 14 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx rename invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/{InitialState.tsx => GenerateLaunchpadPanel.tsx} (81%) rename invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/{InitialStateAddAStyleReference.tsx => LaunchpadAddStyleReference.tsx} (82%) rename invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/{InitialStateButtonGridItem.tsx => LaunchpadButton.tsx} (82%) rename invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/{InitialStateEditImageCard.tsx => LaunchpadEditImageButton.tsx} (69%) rename invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/{InitialStateGenerateFromText.tsx => LaunchpadGenerateFromTextButton.tsx} (66%) rename invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/{InitialStateUseALayoutImageCard.tsx => LaunchpadUseALayoutImageButton.tsx} (70%) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx index fd21523c73..bed3ef2bb6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/AdvancedSession/AdvancedSession.tsx @@ -26,7 +26,7 @@ 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 { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/InitialState'; +import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel'; import { StagingAreaItemsList } from 'features/controlLayers/components/SimpleSession/StagingAreaItemsList'; import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx new file mode 100644 index 0000000000..4e628e6f46 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx @@ -0,0 +1,36 @@ +import { Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; + +import { InitialStateMainModelPicker } from './InitialStateMainModelPicker'; +import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference'; +import { LaunchpadEditImageButton } from './LaunchpadEditImageButton'; +import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton'; +import { LaunchpadUseALayoutImageButton } from './LaunchpadUseALayoutImageButton'; + +export const CanvasLaunchpadPanel = memo(() => { + return ( + + + Get started with Invoke. + + + + + + Want to learn what prompts work best for each model?{' '} + + + + + + + + + + + + ); +}); +CanvasLaunchpadPanel.displayName = 'CanvasLaunchpadPanel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx similarity index 81% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx index 16c6510417..3999f0580f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx @@ -1,11 +1,12 @@ import { Alert, Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { InitialStateAddAStyleReference } from 'features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference'; -import { InitialStateGenerateFromText } from 'features/controlLayers/components/SimpleSession/InitialStateGenerateFromText'; import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker'; +import { LaunchpadAddStyleReference } from 'features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; +import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton'; + export const GenerateLaunchpadPanel = memo(() => { const dispatch = useAppDispatch(); const newCanvasSession = useCallback(() => { @@ -28,8 +29,8 @@ export const GenerateLaunchpadPanel = memo(() => { - - + + Looking to get more control, edit, and iterate on your images? @@ -43,4 +44,4 @@ export const GenerateLaunchpadPanel = memo(() => { ); }); -GenerateLaunchpadPanel.displayName = 'InitialState'; +GenerateLaunchpadPanel.displayName = 'GenerateLaunchpad'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx similarity index 82% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx index 9a1c53a0a4..b4dfcff423 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateAddAStyleReference.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx @@ -1,7 +1,7 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { InitialStateButtonGridItem } from 'features/controlLayers/components/SimpleSession/InitialStateButtonGridItem'; +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'; @@ -13,7 +13,7 @@ import type { ImageDTO } from 'services/api/types'; const dndTargetData = addGlobalReferenceImageDndTarget.getData(); -export const InitialStateAddAStyleReference = memo(() => { +export const LaunchpadAddStyleReference = memo(() => { const { dispatch, getState } = useAppStore(); const uploadOptions = useMemo( @@ -32,7 +32,7 @@ export const InitialStateAddAStyleReference = memo(() => { const uploadApi = useImageUploadButton(uploadOptions); return ( - + Add a Style Reference @@ -43,7 +43,7 @@ export const InitialStateAddAStyleReference = memo(() => { - + ); }); -InitialStateAddAStyleReference.displayName = 'InitialStateAddAStyleReference'; +LaunchpadAddStyleReference.displayName = 'LaunchpadAddStyleReference'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadButton.tsx similarity index 82% rename from invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadButton.tsx index c7b85239d9..150fada4e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/InitialStateButtonGridItem.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadButton.tsx @@ -2,7 +2,7 @@ import type { ButtonProps } from '@invoke-ai/ui-library'; import { Button, forwardRef } from '@invoke-ai/ui-library'; import { memo } from 'react'; -export const InitialStateButtonGridItem = memo( +export const LaunchpadButton = memo( forwardRef(({ children, ...rest }: ButtonProps, ref) => { return ( - ); -}); -StartOverButton.displayName = 'StartOverButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx index 6c49342442..cfa6454baf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton.tsx @@ -2,7 +2,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; @@ -11,6 +11,10 @@ export const CanvasToolbarResetViewButton = memo(() => { const canvasManager = useCanvasManager(); const isCanvasFocused = useIsRegionFocused('canvas'); + const fitLayersToStage = useCallback(() => { + canvasManager.stage.fitLayersToStage(); + }, [canvasManager.stage]); + useRegisteredHotkeys({ id: 'fitLayersToCanvas', category: 'canvas', @@ -58,7 +62,7 @@ export const CanvasToolbarResetViewButton = memo(() => { } variant="link" alignSelf="stretch" diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx index 9e323bd920..7b153a74cf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx @@ -1,16 +1,14 @@ import { Box, Flex } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; -import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; +import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; -import { $hasLastProgressImage } from 'services/events/stores'; import { NoContentForViewer } from './NoContentForViewer'; @@ -82,9 +80,6 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) CurrentImagePreview.displayName = 'CurrentImagePreview'; const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { - const hasProgressImage = useStore($hasLastProgressImage); - const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); - if (!imageDTO) { return ; } diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/UpscaleTabAdvancedSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/UpscaleTabAdvancedSettingsAccordion.tsx index 04b55e80c6..5ac747861a 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/UpscaleTabAdvancedSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/UpscaleTabAdvancedSettingsAccordion.tsx @@ -1,4 +1,3 @@ -import type { FormLabelProps } from '@invoke-ai/ui-library'; import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; @@ -13,14 +12,6 @@ import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetModelConfigQuery } from 'services/api/endpoints/models'; -const formLabelProps: FormLabelProps = { - minW: '9.2rem', -}; - -const formLabelProps2: FormLabelProps = { - flexGrow: 1, -}; - export const AdvancedSettingsAccordion = memo(() => { const vaeKey = useAppSelector(selectVAEKey); const { currentData: vaeConfig } = useGetModelConfigQuery(vaeKey ?? skipToken); diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index 5420511a6d..f810d8e1e4 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -10,54 +10,16 @@ import { GenerateTabAutoLayout } from 'features/ui/layouts/generate-tab-auto-lay import { UpscalingTabAutoLayout } from 'features/ui/layouts/upscaling-tab-auto-layout'; import { WorkflowsTabAutoLayout } from 'features/ui/layouts/workflows-tab-auto-layout'; import { selectActiveTabIndex } from 'features/ui/store/uiSelectors'; -import { $isLeftPanelOpen, $isRightPanelOpen } from 'features/ui/store/uiSlice'; -import type { CSSProperties } from 'react'; import { memo } from 'react'; import { TabMountGate } from './TabMountGate'; import ModelManagerTab from './tabs/ModelManagerTab'; import QueueTab from './tabs/QueueTab'; -const panelStyles: CSSProperties = { position: 'relative', height: '100%', width: '100%', minWidth: 0 }; - -const onLeftPanelCollapse = (isCollapsed: boolean) => $isLeftPanelOpen.set(!isCollapsed); -const onRightPanelCollapse = (isCollapsed: boolean) => $isRightPanelOpen.set(!isCollapsed); - export const AppContent = memo(() => { const tabIndex = useAppSelector(selectActiveTabIndex); useDndMonitor(); - // useRegisteredHotkeys({ - // id: 'resetPanelLayout', - // category: 'app', - // callback: () => { - // leftPanel.reset(); - // rightPanel.reset(); - // }, - // dependencies: [leftPanel.reset, rightPanel.reset], - // }); - // useRegisteredHotkeys({ - // id: 'togglePanels', - // category: 'app', - // callback: () => { - // if (leftPanel.isCollapsed || rightPanel.isCollapsed) { - // leftPanel.expand(); - // rightPanel.expand(); - // } else { - // leftPanel.collapse(); - // rightPanel.collapse(); - // } - // }, - // dependencies: [ - // leftPanel.isCollapsed, - // rightPanel.isCollapsed, - // leftPanel.expand, - // rightPanel.expand, - // leftPanel.collapse, - // rightPanel.collapse, - // ], - // }); - return ( diff --git a/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx deleted file mode 100644 index e30de8bf17..0000000000 --- a/invokeai/frontend/web/src/features/ui/components/LeftPanelContent.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Box, Flex } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import WorkflowsTabLeftPanel from 'features/nodes/components/sidePanel/WorkflowsTabLeftPanel'; -import QueueControls from 'features/queue/components/QueueControls'; -import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { memo } from 'react'; - -import ParametersPanelUpscale from './ParametersPanels/ParametersPanelUpscale'; - -export const LeftPanelContent = memo(() => { - const tab = useAppSelector(selectActiveTab); - - return ( - - - - {tab === 'generate' && } - {tab === 'canvas' && } - {tab === 'upscaling' && } - {tab === 'workflows' && } - - - ); -}); -LeftPanelContent.displayName = 'LeftPanelContent'; diff --git a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx deleted file mode 100644 index 04bf65154d..0000000000 --- a/invokeai/frontend/web/src/features/ui/components/RightPanelContent.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { useDisclosure } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; -import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; -import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; -import { GalleryPanel } from 'features/gallery/components/Gallery'; -import { GalleryTopBar } from 'features/gallery/components/GalleryTopBar'; -import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; -import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle'; -import type { UsePanelOptions } from 'features/ui/hooks/usePanel'; -import { usePanel } from 'features/ui/hooks/usePanel'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { memo, useMemo, useRef } from 'react'; -import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; -import { Panel, PanelGroup } from 'react-resizable-panels'; - -const FOCUS_REGION_STYLES: SystemStyleObject = { - width: 'full', - height: 'full', - position: 'relative', - flexDirection: 'column', - display: 'flex', -}; - -export const RightPanelContent = memo(() => { - const boardSearchText = useAppSelector(selectBoardSearchText); - const boardSearchDisclosure = useDisclosure({ defaultIsOpen: !!boardSearchText.length }); - const imperativePanelGroupRef = useRef(null); - const tab = useAppSelector(selectActiveTab); - - const boardsListPanelOptions = useMemo( - () => ({ - id: 'boards-list-panel', - minSizePx: 128, - defaultSizePx: 256, - imperativePanelGroupRef, - panelGroupDirection: 'vertical', - }), - [] - ); - const boardsListPanel = usePanel(boardsListPanelOptions); - - const galleryPanelOptions = useMemo( - () => ({ - id: 'gallery-panel', - minSizePx: 128, - defaultSizePx: 256, - imperativePanelGroupRef, - panelGroupDirection: 'vertical', - }), - [] - ); - const galleryPanel = usePanel(galleryPanelOptions); - - const canvasLayersPanelOptions = useMemo( - () => ({ - id: 'canvas-layers-panel', - minSizePx: 128, - defaultSizePx: 256, - imperativePanelGroupRef, - panelGroupDirection: 'vertical', - }), - [] - ); - const canvasLayersPanel = usePanel(canvasLayersPanelOptions); - - return ( - - - - - - - - - - - {tab === 'canvas' && ( - <> - - - - - - - - )} - - - ); -}); -RightPanelContent.displayName = 'RightPanelContent'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/AutoLayout.tsx b/invokeai/frontend/web/src/features/ui/layouts/AutoLayout.tsx deleted file mode 100644 index 4422adac97..0000000000 --- a/invokeai/frontend/web/src/features/ui/layouts/AutoLayout.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { Flex } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; -import { DockviewReact, GridviewReact, Orientation } from 'dockview'; -import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; -import { CanvasLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel'; -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'; -import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; -import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context'; -import { CanvasWorkspacePanel } from 'features/ui/layouts/CanvasWorkspacePanel'; -import { GenerateLeftPanel } from 'features/ui/layouts/generate-tab-auto-layout'; -import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_MIN_SIZE_PX } from 'features/ui/store/uiSlice'; -import type { TabName } from 'features/ui/store/uiTypes'; -import { dockviewTheme } from 'features/ui/styles/theme'; -import { atom } from 'nanostores'; -import { memo, useCallback, useEffect, useState } from 'react'; - -export const dockviewComponents: IDockviewReactProps['components'] = { - // Shared components - ImageViewer: ImageViewerPanel, - GenerationProgress: GenerationProgressPanel, - // Generate tab - GenerateLaunchpad: GenerateLaunchpadPanel, - // Upscaling tab - UpscalingLaunchpad: GenerateLaunchpadPanel, - // Workflows tab - WorkflowsLaunchpad: GenerateLaunchpadPanel, - // Canvas tab - CanvasLaunchpad: CanvasLaunchpadPanel, - CanvasWorkspace: CanvasWorkspacePanel, -}; - -const getGenerateLaunchpadPanel = (api: DockviewApi) => { - return ( - api.getPanel('GenerateLaunchpad') || - api.addPanel({ - id: 'GenerateLaunchpad', - component: 'GenerateLaunchpad', - title: 'Launchpad', - }) - ); -}; - -const getCanvasLaunchpadPanel = (api: DockviewApi) => { - return ( - api.getPanel('CanvasLaunchpad') || - api.addPanel({ - id: 'CanvasLaunchpad', - component: 'CanvasLaunchpad', - title: 'Launchpad', - }) - ); -}; - -const getCanvasWorkspacePanel = (api: DockviewApi) => { - return ( - api.getPanel('CanvasWorkspace') || - api.addPanel({ - id: 'CanvasWorkspace', - component: 'CanvasWorkspace', - title: 'Canvas', - }) - ); -}; - -const getImageViewerPanel = (api: DockviewApi) => { - return ( - api.getPanel('ImageViewer') || - api.addPanel({ - id: 'ImageViewer', - component: 'ImageViewer', - title: 'Image Viewer', - }) - ); -}; - -const getGenerationProgressPanel = (api: DockviewApi) => { - return ( - api.getPanel('GenerationProgress') || - api.addPanel({ - id: 'GenerationProgress', - component: 'GenerationProgress', - title: 'Generation Progress', - }) - ); -}; - -const syncMainPanelLayout = (tab: TabName, api: DockviewApi) => { - if (tab === 'generate') { - const GenerateLaunchpad = getGenerateLaunchpadPanel(api); - const ImageViewer = getImageViewerPanel(api); - const GenerationProgress = getGenerationProgressPanel(api); - const panelsToKeep = [GenerateLaunchpad.id, ImageViewer.id, GenerationProgress.id]; - for (const panel of api.panels) { - if (!panelsToKeep.includes(panel.id)) { - api.removePanel(panel); - } - } - } else if (tab === 'canvas') { - const CanvasLaunchpad = getCanvasLaunchpadPanel(api); - const CanvasWorkspace = getCanvasWorkspacePanel(api); - const ImageViewer = getImageViewerPanel(api); - const GenerationProgress = getGenerationProgressPanel(api); - const panelsToKeep = [CanvasLaunchpad.id, CanvasWorkspace.id, ImageViewer.id, GenerationProgress.id]; - for (const panel of api.panels) { - if (!panelsToKeep.includes(panel.id)) { - api.removePanel(panel); - } - } - } -}; - -const MainPanel = memo(() => { - const tab = useAppSelector(selectActiveTab); - const [api, setApi] = useState(null); - const onReady = useCallback((event) => { - console.log('MainPanel onReady', event.api); - setApi(event.api); - }, []); - - useEffect(() => { - if (api) { - syncMainPanelLayout(tab, api); - } - }, [api, tab]); - - return ( - - - - ); -}); -MainPanel.displayName = 'MainPanel'; - -export const gridviewComponents: IGridviewReactProps['components'] = { - // Shared components - Gallery: GalleryPanel, - Boards: BoardsPanel, - Main: MainPanel, - GenerateLeft: GenerateLeftPanel, - CanvasLeft: GenerateLeftPanel, - CanvasLayers: CanvasLayersPanel, -}; - -const syncGridviewLayout = (tab: TabName, api: GridviewApi) => { - if (tab === 'generate') { - const MainPanel = - api.getPanel('Main') ?? - api.addPanel({ - id: 'Main', - component: 'Main', - }); - - const GenerateLeftPanel = - api.getPanel('GenerateLeft') ?? - api.addPanel({ - id: 'GenerateLeft', - component: 'GenerateLeft', - minimumWidth: LEFT_PANEL_MIN_SIZE_PX, - position: { - direction: 'left', - referencePanel: MainPanel.id, - }, - }); - - const GalleryPanel = - api.getPanel('Gallery') ?? - api.addPanel({ - id: 'Gallery', - component: 'Gallery', - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: 232, - position: { - direction: 'right', - referencePanel: MainPanel.id, - }, - }); - - const BoardsPanel = - api.getPanel('Boards') ?? - api.addPanel({ - id: 'Boards', - component: 'Boards', - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: 36, - position: { - direction: 'above', - referencePanel: GalleryPanel.id, - }, - }); - - const panelsToKeep = [MainPanel.id, GenerateLeftPanel.id, GalleryPanel.id, BoardsPanel.id]; - for (const panel of api.panels) { - if (!panelsToKeep.includes(panel.id)) { - api.removePanel(panel); - } - } - } else if (tab === 'canvas') { - const MainPanel = - api.getPanel('Main') ?? - api.addPanel({ - id: 'Main', - component: 'Main', - }); - - const CanvasLeftPanel = - api.getPanel('CanvasLeft') ?? - api.addPanel({ - id: 'CanvasLeft', - component: 'CanvasLeft', - minimumWidth: LEFT_PANEL_MIN_SIZE_PX, - position: { - direction: 'left', - referencePanel: MainPanel.id, - }, - }); - - const GalleryPanel = - api.getPanel('Gallery') ?? - api.addPanel({ - id: 'Gallery', - component: 'Gallery', - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: 232, - position: { - direction: 'right', - referencePanel: MainPanel.id, - }, - }); - - const BoardsPanel = - api.getPanel('Boards') ?? - api.addPanel({ - id: 'Boards', - component: 'Boards', - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: 36, - position: { - direction: 'above', - referencePanel: GalleryPanel.id, - }, - }); - - const CanvasLayersPanel = - api.getPanel('CanvasLayers') ?? - api.addPanel({ - id: 'CanvasLayers', - component: 'CanvasLayers', - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: 232, - position: { - direction: 'below', - referencePanel: GalleryPanel.id, - }, - }); - - const panelsToKeep = [MainPanel.id, CanvasLeftPanel.id, GalleryPanel.id, BoardsPanel.id, CanvasLayersPanel.id]; - for (const panel of api.panels) { - if (!panelsToKeep.includes(panel.id)) { - api.removePanel(panel); - } - } - } -}; - -export const AutoLayout = memo(() => { - const tab = useAppSelector(selectActiveTab); - const $api = useState(() => atom(null))[0]; - const onReady = useCallback( - (event) => { - $api.set(event.api); - }, - [$api] - ); - useEffect(() => { - const api = $api.get(); - if (api) { - syncGridviewLayout(tab, api); - } - }, [$api, tab]); - return ( - - - - ); -}); -AutoLayout.displayName = 'AutoLayout'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx index 0d4803adca..179bb4bfb3 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx @@ -12,7 +12,6 @@ export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { }, [props.api]); useCallbackOnDragEnter(setActive, ref, 300); - console.log(props.api.title); return ( diff --git a/invokeai/frontend/web/src/features/ui/layouts/components.ts b/invokeai/frontend/web/src/features/ui/layouts/components.ts deleted file mode 100644 index 3182aebe91..0000000000 --- a/invokeai/frontend/web/src/features/ui/layouts/components.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IDockviewReactProps, IGridviewReactProps } from 'dockview'; -import { CanvasLayersPanel } from 'features/controlLayers/components/CanvasLayersPanelContent'; -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'; -import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; -import { CanvasWorkspacePanel } from 'features/ui/layouts/canvas-tab-auto-layout'; -import { GenerateLeftPanel } from 'features/ui/layouts/generate-tab-auto-layout'; - -export const components: IDockviewReactProps['components'] & IGridviewReactProps['components'] = { - // Shared components - Gallery: GalleryPanel, - Boards: BoardsPanel, - ImageViewer: ImageViewerPanel, - GenerationProgress: GenerationProgressPanel, - // Generate tab - GenerateLaunchpad: GenerateLaunchpadPanel, - GenerateLeft: GenerateLeftPanel, - // Upscaling tab - UpscalingLaunchpad: GenerateLaunchpadPanel, - // Workflows tab - WorkflowsLaunchpad: GenerateLaunchpadPanel, - // Canvas tab - CanvasLaunchpad: GenerateLaunchpadPanel, - CanvasLayers: CanvasLayersPanel, - CanvasWorkspace: CanvasWorkspacePanel, - CanvasLeft: GenerateLeftPanel, -}; From 33a28ad4f957dd4d552d4b0919e8b42289ed015c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:41:01 +1000 Subject: [PATCH 122/210] chore: bump version to v6.0.0a3 --- 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 c7f18a7bc8..9a0311fe55 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "6.0.0a2" +__version__ = "6.0.0a3" From 241844bdefc2c04dcec63c5a34d486cc45440c46 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:27:14 +1000 Subject: [PATCH 123/210] refactor(ui): rip out image viewer as modal --- .../src/app/components/GlobalHookIsolator.tsx | 8 -- .../app/components/GlobalModalIsolator.tsx | 2 - .../web/src/app/hooks/useStudioInitAction.ts | 4 - .../web/src/features/dnd/DndImage.tsx | 4 - .../src/features/dnd/FullscreenDropzone.tsx | 13 +-- ...ImageMenuItemNewCanvasFromImageSubMenu.tsx | 14 +-- .../ImageMenuItemNewLayerFromImageSubMenu.tsx | 17 +-- .../ImageMenuItemOpenInViewer.tsx | 7 +- .../ImageMenuItemUseAsRefImage.tsx | 5 +- .../components/ImageGrid/GalleryImage.tsx | 4 - .../GalleryImageOpenInViewerIconButton.tsx | 7 +- .../components/ImageViewer/ImageViewer.tsx | 105 +----------------- .../components/ImageViewer/ImageViewer2.tsx | 85 +------------- .../components/ImageViewer/useImageViewer.ts | 62 ----------- 14 files changed, 20 insertions(+), 317 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts diff --git a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx index 17bb4f4ce4..f9644d96ac 100644 --- a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx @@ -11,11 +11,9 @@ import { useFocusRegionWatcher } from 'common/hooks/focus'; import { useCloseChakraTooltipsOnDragFix } from 'common/hooks/useCloseChakraTooltipsOnDragFix'; import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys'; import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher'; -import { toggleImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher'; import { useReadinessWatcher } from 'features/queue/store/readiness'; -import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { configChanged } from 'features/system/store/configSlice'; import { selectLanguage } from 'features/system/store/systemSelectors'; import i18n from 'i18n'; @@ -72,12 +70,6 @@ export const GlobalHookIsolator = memo( useWorkflowBuilderWatcher(); useDynamicPromptsWatcher(); - useRegisteredHotkeys({ - id: 'toggleViewer', - category: 'viewer', - callback: toggleImageViewer, - }); - return null; } ); diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index b7bacf3d2c..662994ec41 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -11,7 +11,6 @@ import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; -import { ImageViewerModal } from 'features/gallery/components/ImageViewer/ImageViewer'; import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal'; import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal'; import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; @@ -61,7 +60,6 @@ export const GlobalModalIsolator = memo(() => { - ); }); diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts index b08d40417a..7dfaae2759 100644 --- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -7,7 +7,6 @@ import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; import { paramsReset } from 'features/controlLayers/store/paramsSlice'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { imageDTOToImageObject } from 'features/controlLayers/store/util'; -import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { sentImageToCanvas } from 'features/gallery/store/actions'; import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers'; import { $hasTemplates } from 'features/nodes/store/nodesSlice'; @@ -94,7 +93,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => { store.dispatch(canvasReset()); store.dispatch(rasterLayerAdded({ overrides, isSelected: true })); store.dispatch(sentImageToCanvas()); - $imageViewer.set(false); toast({ title: t('toast.sentToCanvas'), status: 'info', @@ -164,12 +162,10 @@ export const useStudioInitAction = (action?: StudioInitAction) => { // Go to the canvas tab, open the image viewer, and enable send-to-gallery mode store.dispatch(paramsReset()); store.dispatch(activeTabCanvasRightPanelChanged('gallery')); - $imageViewer.set(true); break; case 'canvas': // Go to the canvas tab, close the image viewer, and disable send-to-gallery mode store.dispatch(canvasReset()); - $imageViewer.set(false); break; case 'workflows': // Go to the workflows tab diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 9aed1798b4..01ac203653 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -8,7 +8,6 @@ import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreview import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { firefoxDndFix } from 'features/dnd/util'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; -import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -48,9 +47,6 @@ export const DndImage = memo( getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name), onDragStart: () => { setIsDragging(true); - if ($imageViewer.get()) { - $imageViewer.set(false); - } }, onDrop: () => { setIsDragging(false); diff --git a/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx index dba20d4e32..84537b7d16 100644 --- a/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx +++ b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx @@ -4,7 +4,6 @@ import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/exter import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Heading } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; import { getStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { $focusedRegion } from 'common/hooks/focus'; @@ -12,7 +11,6 @@ import { useClientSideUpload } from 'common/hooks/useClientSideUpload'; import { setFileToPaste } from 'features/controlLayers/components/CanvasPasteModal'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import type { DndTargetState } from 'features/dnd/types'; -import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice'; import { toast } from 'features/toast/toast'; @@ -70,7 +68,6 @@ export const FullscreenDropzone = memo(() => { const ref = useRef(null); const [dndState, setDndState] = useState('idle'); const activeTab = useAppSelector(selectActiveTab); - const isImageViewerOpen = useStore($imageViewer); const isClientSideUploadEnabled = useAppSelector(selectIsClientSideUploadEnabled); const clientSideUpload = useClientSideUpload(); @@ -96,13 +93,7 @@ export const FullscreenDropzone = memo(() => { // While on the canvas tab and when pasting a single image, canvas may want to create a new layer. Let it handle // the paste event. const [firstImageFile] = files; - if ( - focusedRegion === 'canvas' && - !isImageViewerOpen && - activeTab === 'canvas' && - files.length === 1 && - firstImageFile - ) { + if (focusedRegion === 'canvas' && activeTab === 'canvas' && files.length === 1 && firstImageFile) { setFileToPaste(firstImageFile); return; } @@ -125,7 +116,7 @@ export const FullscreenDropzone = memo(() => { uploadImages(uploadArgs); } }, - [activeTab, isImageViewerOpen, t, isClientSideUploadEnabled, clientSideUpload] + [activeTab, t, isClientSideUploadEnabled, clientSideUpload] ); const onPaste = useCallback( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx index d2ce3a3cfc..405585546f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx @@ -2,7 +2,6 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { newCanvasFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; @@ -16,56 +15,51 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => { const subMenu = useSubMenu(); const store = useAppStore(); const imageDTO = useImageDTOContext(); - const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusySafe(); const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => { const { dispatch, getState } = store; await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState }); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => { const { dispatch, getState } = store; await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState }); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => { const { dispatch, getState } = store; await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState }); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => { const { dispatch, getState } = store; await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState }); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); return ( }> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx index 4fc9934f07..0f751bde33 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx @@ -3,7 +3,6 @@ import { useAppStore } from 'app/store/nanostores/store'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { sentImageToCanvas } from 'features/gallery/store/actions'; import { createNewCanvasEntityFromImage } from 'features/imageActions/actions'; @@ -18,7 +17,6 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { const subMenu = useSubMenu(); const store = useAppStore(); const imageDTO = useImageDTOContext(); - const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusySafe(); const onClickNewRasterLayerFromImage = useCallback(() => { @@ -26,65 +24,60 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); const onClickNewControlLayerFromImage = useCallback(() => { const { dispatch, getState } = store; createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); const onClickNewInpaintMaskFromImage = useCallback(() => { const { dispatch, getState } = store; createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); const onClickNewRegionalGuidanceFromImage = useCallback(() => { const { dispatch, getState } = store; createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); const onClickNewRegionalReferenceImageFromImage = useCallback(() => { const { dispatch, getState } = store; createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); return ( }> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx index 1e069f4263..aa68d5460e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx @@ -1,5 +1,4 @@ import { IconMenuItem } from 'common/components/IconMenuItem'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,10 +7,10 @@ import { PiArrowsOutBold } from 'react-icons/pi'; export const ImageMenuItemOpenInViewer = memo(() => { const { t } = useTranslation(); const imageDTO = useImageDTOContext(); - const imageViewer = useImageViewer(); const onClick = useCallback(() => { - imageViewer.openImageInViewer(imageDTO); - }, [imageDTO, imageViewer]); + // TODO + imageDTO.image_name; + }, [imageDTO]); return ( { const { t } = useTranslation(); const store = useAppStore(); const imageDTO = useImageDTOContext(); - const imageViewer = useImageViewer(); const onClickNewGlobalReferenceImageFromImage = useCallback(() => { const { dispatch, getState } = store; const config = getDefaultRefImageConfig(getState); config.image = imageDTOToImageWithDims(imageDTO); dispatch(refImageAdded({ overrides: { config } })); - imageViewer.close(); toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), status: 'success', }); - }, [imageDTO, imageViewer, store, t]); + }, [imageDTO, store, t]); return ( } onClickCapture={onClickNewGlobalReferenceImageFromImage}> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 87ae975857..3bf55181a3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -15,7 +15,6 @@ import { firefoxDndFix } from 'features/dnd/util'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; -import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; @@ -203,9 +202,6 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { ); const onDoubleClick = useCallback>(() => { - // Use the atom here directly instead of the `useImageViewer` to avoid re-rendering the gallery when the viewer - // opened state changes. - $imageViewer.set(true); store.dispatch(imageToCompareChanged(null)); }, [store]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx index 55c4a68e82..bf58669bf6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -1,5 +1,4 @@ import { DndImageIcon } from 'features/dnd/DndImageIcon'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; @@ -10,12 +9,12 @@ type Props = { }; export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => { - const imageViewer = useImageViewer(); const { t } = useTranslation(); const onClick = useCallback(() => { - imageViewer.openImageInViewer(imageDTO); - }, [imageDTO, imageViewer]); + // TODO + imageDTO.image_name; + }, [imageDTO]); return ( { const lastSelectedImageName = useAppSelector(selectLastSelectedImageName); const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken); @@ -48,78 +20,3 @@ export const ImageViewer = memo(() => { }); ImageViewer.displayName = 'ImageViewer'; - -const imageViewerContainerSx: SystemStyleObject = { - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0, - transition: 'opacity 0.15s ease', - opacity: 1, - pointerEvents: 'auto', - '&[data-hidden="true"]': { - opacity: 0, - pointerEvents: 'none', - }, - backdropFilter: 'blur(10px) brightness(70%)', -}; - -export const ImageViewerModal = memo(() => { - const ref = useRef(null); - const imageViewer = useImageViewer(); - useOutsideClick({ - ref, - handler: imageViewer.close, - }); - - useHotkeys( - 'esc', - imageViewer.close, - { - preventDefault: true, - enabled: imageViewer.isOpen, - }, - [imageViewer.isOpen] - ); - - return ( - - - - - - - ); -}); - -ImageViewerModal.displayName = 'GatedImageViewer'; - -const ImageViewerCloseButton = memo(() => { - const { t } = useTranslation(); - const imageViewer = useImageViewer(); - useAssertSingleton('ImageViewerCloseButton'); - useHotkeys('esc', imageViewer.close); - return ( - } - variant="link" - alignSelf="stretch" - onClick={imageViewer.close} - /> - ); -}); - -ImageViewerCloseButton.displayName = 'ImageViewerCloseButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx index 07cab441af..510a974a25 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx @@ -1,20 +1,12 @@ -import { Box, Flex, IconButton, type SystemStyleObject, useOutsideClick } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; -import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common'; import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview2'; import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison'; -import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; -import { memo, useRef } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { useTranslation } from 'react-i18next'; -import { PiXBold } from 'react-icons/pi'; +import { memo } from 'react'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import { useImageViewer } from './useImageViewer'; - // type Props = { // closeButton?: ReactNode; // }; @@ -48,78 +40,3 @@ export const ImageViewer = memo(() => { }); ImageViewer.displayName = 'ImageViewer'; - -const imageViewerContainerSx: SystemStyleObject = { - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0, - transition: 'opacity 0.15s ease', - opacity: 1, - pointerEvents: 'auto', - '&[data-hidden="true"]': { - opacity: 0, - pointerEvents: 'none', - }, - backdropFilter: 'blur(10px) brightness(70%)', -}; - -export const ImageViewerModal = memo(() => { - const ref = useRef(null); - const imageViewer = useImageViewer(); - useOutsideClick({ - ref, - handler: imageViewer.close, - }); - - useHotkeys( - 'esc', - imageViewer.close, - { - preventDefault: true, - enabled: imageViewer.isOpen, - }, - [imageViewer.isOpen] - ); - - return ( - - - - - - - ); -}); - -ImageViewerModal.displayName = 'GatedImageViewer'; - -const ImageViewerCloseButton = memo(() => { - const { t } = useTranslation(); - const imageViewer = useImageViewer(); - useAssertSingleton('ImageViewerCloseButton'); - useHotkeys('esc', imageViewer.close); - return ( - } - variant="link" - alignSelf="stretch" - onClick={imageViewer.close} - /> - ); -}); - -ImageViewerCloseButton.displayName = 'ImageViewerCloseButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts deleted file mode 100644 index 038c5ee607..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useAppDispatch } from 'app/store/storeHooks'; -import { buildUseBoolean } from 'common/hooks/useBoolean'; -import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; -import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; -import { useCallback } from 'react'; -import type { ImageDTO } from 'services/api/types'; - -/** - * There's a race condition that causes the canvas to not fit to layers on the very first app startup. - * - * The canvas stage uses a resize observer to fit the stage to the container, and on the first resize event, it also - * fits the layers to the stage. Subsequent resize events only fit the stage to the container, they do not fit layers - * to the stage. - * - * On the very first app startup (new user or after they reset all web UI state), the resizable panels library needs - * to do one extra resize as it initializes and figures out its target size. At this time, the canvas stage has already - * done its one-time fit layers to stage, so the canvas stage does not fit layers to the stage again. - * - * For the end user, this means that the bbox is not centered in the canvas stage on the very first app startup. On - * all subsequent app startups, the bbox is centered in the canvas stage. - * - * We can hack around this, thanks to the fact that the image viewer is always opened on the first app startup. By the - * time the user closes it, the resizable panels library has already done its one extra resize and the DOM layout has - * stabilized. So we can track the first time the image viewer is closed and fit the layers to the stage at that time, - * ensuring that the bbox is centered in the canvas stage on that first app startup. - * - * TODO(psyche): Figure out a better way to do handle this... - */ -let didCloseImageViewer = false; -const api = buildUseBoolean(false); -const useImageViewerState = api[0]; -export const $imageViewer = api[1]; -export const toggleImageViewer = () => $imageViewer.set(!$imageViewer.get()); - -export const useImageViewer = () => { - const dispatch = useAppDispatch(); - const canvasManager = useCanvasManagerSafe(); - const imageViewerState = useImageViewerState(); - const close = useCallback(() => { - if (!didCloseImageViewer && canvasManager) { - didCloseImageViewer = true; - canvasManager.stage.fitLayersToStage(); - } - imageViewerState.setFalse(); - }, [canvasManager, imageViewerState]); - const openImageInViewer = useCallback( - (imageDTO: ImageDTO) => { - dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(imageDTO)); - imageViewerState.setTrue(); - }, - [dispatch, imageViewerState] - ); - - return { - isOpen: imageViewerState.isTrue, - open: imageViewerState.setTrue, - close, - toggle: imageViewerState.toggle, - openImageInViewer, - }; -}; From 01953cf057ee40511d09d64019350393800b7abc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:12:10 +1000 Subject: [PATCH 124/210] feat(ui): tweak dockview tabs --- .../web/src/features/ui/layouts/TabWithoutCloseButton.tsx | 2 +- .../web/src/features/ui/styles/dockview-theme-invoke.css | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx index 179bb4bfb3..84dbb1e7ad 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx @@ -14,7 +14,7 @@ export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { useCallbackOnDragEnter(setActive, ref, 300); return ( - + {props.api.title ?? props.api.id} ); diff --git a/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css b/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css index 06a1e99c58..49aab65e53 100644 --- a/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css +++ b/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css @@ -1,7 +1,7 @@ .dockview-theme-invoke { --dv-paneview-active-outline-color: var(--invoke-colors-invokeBlue-300); --dv-tabs-and-actions-container-font-size: var(--invoke-fontSizes-sm); - --dv-tabs-and-actions-container-height: var(--invoke-sizes-8); + --dv-tabs-and-actions-container-height: var(--invoke-sizes-10); --dv-drag-over-background-color: var(--invoke-colors-baseAlpha-400); --dv-drag-over-border-color: var(--invoke-colors-base-300); --dv-tabs-container-scrollbar-color: #888; @@ -57,6 +57,8 @@ .dv-tab { /* margin-right: 2px; */ + padding-inline-start: var(--invoke-sizes-4); + padding-inline-end: var(--invoke-sizes-4); } .dv-inactive-group .dv-tabs-container.dv-horizontal .dv-tab:not(:first-child)::before { From 852badc90b911a5fc87396311565784ed046a6ca Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:25:59 +1000 Subject: [PATCH 125/210] feat(ui): standardize auto layout structure --- .../gallery/components/GalleryTopBar.tsx | 25 +-- .../ImageMenuItemOpenInViewer.tsx | 10 +- .../components/ImageGrid/GalleryImage.tsx | 5 +- .../GalleryImageOpenInViewerIconButton.tsx | 12 +- .../ui/layouts/auto-layout-context.tsx | 80 +++++++--- .../ui/layouts/canvas-tab-auto-layout.tsx | 142 ++++++++++++------ .../ui/layouts/generate-tab-auto-layout.tsx | 138 +++++++++++------ .../web/src/features/ui/layouts/shared.ts | 11 ++ .../ui/layouts/upscaling-tab-auto-layout.tsx | 125 +++++++++------ .../ui/layouts/workflows-tab-auto-layout.tsx | 140 +++++++++++------ 10 files changed, 452 insertions(+), 236 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx index 20634f7450..68037da8a2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryTopBar.tsx @@ -1,48 +1,31 @@ -import { Button, Flex, IconButton } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useBoardSearchDisclosure } from 'features/gallery/components/Boards/BoardsList/BoardsSearch'; import { BoardsSettingsPopover } from 'features/gallery/components/Boards/BoardsSettingsPopover'; import { GalleryHeader } from 'features/gallery/components/GalleryHeader'; import { selectBoardSearchText } from 'features/gallery/store/gallerySelectors'; import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice'; -import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; -import { useCollapsibleGridviewPanel } from 'features/ui/layouts/use-collapsible-gridview-panel'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi'; +import { PiMagnifyingGlassBold } from 'react-icons/pi'; export const GalleryTopBar = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const boardSearchText = useAppSelector(selectBoardSearchText); const boardSearchDisclosure = useBoardSearchDisclosure(); - const { $api } = useAutoLayoutContext(); - const api = useStore($api); - const boardsPanel = useCollapsibleGridviewPanel(api, 'Boards', 'vertical', 256); - const isBoardsPanelCollapsed = useStore(boardsPanel.$isCollapsed); const onClickBoardSearch = useCallback(() => { if (boardSearchText.length) { dispatch(boardSearchTextChanged('')); } - if (!boardSearchDisclosure.isOpen && boardsPanel.$isCollapsed.get()) { - boardsPanel.expand(); - } boardSearchDisclosure.toggle(); - }, [boardSearchText.length, boardSearchDisclosure, dispatch, boardsPanel]); + }, [boardSearchText.length, boardSearchDisclosure, dispatch]); return ( - + Boards diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx index aa68d5460e..f6753316b6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx @@ -1,16 +1,20 @@ +import { useAppDispatch } from 'app/store/storeHooks'; import { IconMenuItem } from 'common/components/IconMenuItem'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; export const ImageMenuItemOpenInViewer = memo(() => { + const dispatch = useAppDispatch(); const { t } = useTranslation(); const imageDTO = useImageDTOContext(); const onClick = useCallback(() => { - // TODO - imageDTO.image_name; - }, [imageDTO]); + dispatch(imageToCompareChanged(null)); + dispatch(imageSelected(imageDTO)); + // TODO: figure out how to select the closest image viewer... + }, [dispatch, imageDTO]); return ( { const store = useAppStore(); + const autoLayoutContext = useAutoLayoutContext(); const [isDragging, setIsDragging] = useState(false); const [dragPreviewState, setDragPreviewState] = useState< DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null @@ -203,7 +205,8 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { const onDoubleClick = useCallback>(() => { store.dispatch(imageToCompareChanged(null)); - }, [store]); + autoLayoutContext.focusImageViewer(); + }, [autoLayoutContext, store]); const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx index bf58669bf6..ac694369b9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -1,4 +1,7 @@ +import { useAppDispatch } from 'app/store/storeHooks'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; +import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; @@ -9,12 +12,15 @@ type Props = { }; export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => { + const dispatch = useAppDispatch(); + const { focusImageViewer } = useAutoLayoutContext(); const { t } = useTranslation(); const onClick = useCallback(() => { - // TODO - imageDTO.image_name; - }, [imageDTO]); + dispatch(imageToCompareChanged(null)); + dispatch(imageSelected(imageDTO)); + focusImageViewer(); + }, [dispatch, focusImageViewer, imageDTO]); return ( ; toggleLeftPanel: () => void; toggleRightPanel: () => void; toggleBothPanels: () => void; resetPanels: () => void; + focusImageViewer: () => void; + _$rootPanelApi: WritableAtom; + _$leftPanelApi: WritableAtom; + _$centerPanelApi: WritableAtom; + _$rightPanelApi: WritableAtom; }; const AutoLayoutContext = createContext(null); @@ -42,9 +53,22 @@ const getIsCollapsed = (api: GridviewApi, panelId: string) => { return panel.maximumWidth === 0; }; -export const AutoLayoutProvider = (props: PropsWithChildren<{ $api: Atom }>) => { +const activatePanel = (api: GridviewApi | DockviewApi, panelId: string) => { + const panel = api.getPanel(panelId); + if (!panel) { + return; + } + panel.api.setActive(); +}; + +export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: WritableAtom }>) => { + const { $rootApi, children } = props; + const $leftApi = useState(() => atom(null))[0]; + const $centerApi = useState(() => atom(null))[0]; + const $rightApi = useState(() => atom(null))[0]; + const toggleLeftPanel = useCallback(() => { - const api = props.$api.get(); + const api = $rootApi.get(); if (!api) { return; } @@ -53,10 +77,10 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $api: Atom { - const api = props.$api.get(); + const api = $rootApi.get(); if (!api) { return; } @@ -65,10 +89,10 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $api: Atom { - const api = props.$api.get(); + const api = $rootApi.get(); if (!api) { return; } @@ -81,28 +105,50 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $api: Atom { - const api = props.$api.get(); + const api = $rootApi.get(); if (!api) { return; } expandPanel(api, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX); expandPanel(api, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX); - }, [props.$api]); + }, [$rootApi]); + + const focusImageViewer = useCallback(() => { + const api = $centerApi.get(); + if (!api) { + return; + } + activatePanel(api, VIEWER_PANEL_ID); + }, [$centerApi]); const value = useMemo( () => ({ - $api: props.$api, toggleLeftPanel, toggleRightPanel, toggleBothPanels, resetPanels, + focusImageViewer, + _$rootPanelApi: $rootApi, + _$leftPanelApi: $leftApi, + _$centerPanelApi: $centerApi, + _$rightPanelApi: $rightApi, }), - [props.$api, resetPanels, toggleBothPanels, toggleLeftPanel, toggleRightPanel] + [ + $centerApi, + $leftApi, + $rightApi, + $rootApi, + focusImageViewer, + resetPanels, + toggleBothPanels, + toggleLeftPanel, + toggleRightPanel, + ] ); - return {props.children}; + return {children}; }; export const useAutoLayoutContext = () => { 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 b77a196c56..b4e5971972 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,4 +1,4 @@ -import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; +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'; @@ -8,7 +8,7 @@ import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import { FloatingCanvasLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; -import { AutoLayoutProvider, PanelHotkeysLogical } from 'features/ui/layouts/auto-layout-context'; +import { AutoLayoutProvider, PanelHotkeysLogical, useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; import { dockviewTheme } from 'features/ui/styles/theme'; import { atom } from 'nanostores'; @@ -17,28 +17,30 @@ import { memo, useCallback, useRef, useState } from 'react'; import { CanvasTabLeftPanel } from './CanvasTabLeftPanel'; import { CanvasWorkspacePanel } from './CanvasWorkspacePanel'; import { + BOARDS_PANEL_ID, + GALLERY_PANEL_ID, + LAUNCHPAD_PANEL_ID, + LAYERS_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, MAIN_PANEL_ID, + PROGRESS_PANEL_ID, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, + SETTINGS_PANEL_ID, + VIEWER_PANEL_ID, + WORKSPACE_PANEL_ID, } from './shared'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; -const LAUNCHPAD_PANEL_ID = 'launchpad'; -const WORKSPACE_PANEL_ID = 'workspace'; -const VIEWER_PANEL_ID = 'viewer'; -const PROGRESS_PANEL_ID = 'progress'; - -const mainPanelComponents: IDockviewReactProps['components'] = { +const centerPanelComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: CanvasLaunchpadPanel, [WORKSPACE_PANEL_ID]: CanvasWorkspacePanel, [VIEWER_PANEL_ID]: ImageViewerPanel, [PROGRESS_PANEL_ID]: GenerationProgressPanel, }; -const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { - const { api } = event; +const initializeCenterPanelLayout = (api: DockviewApi) => { api.addPanel({ id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, @@ -73,24 +75,31 @@ const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); - - const disposables = [ - api.onWillShowOverlay((e) => { - if (e.kind === 'header_space' || e.kind === 'tab') { - return; - } - e.preventDefault(); - }), - ]; - - return () => { - disposables.forEach((disposable) => { - disposable.dispose(); - }); - }; }; -const MainPanel = memo(() => { +const CenterPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeCenterPanelLayout(event.api); + ctx._$centerPanelApi.set(event.api); + const disposables = [ + event.api.onWillShowOverlay((e) => { + if (e.kind === 'header_space' || e.kind === 'tab') { + return; + } + e.preventDefault(); + }), + ]; + + return () => { + disposables.forEach((disposable) => { + disposable.dispose(); + }); + }; + }, + [ctx._$centerPanelApi] + ); return ( <> { disableFloatingGroups={true} dndEdges={false} defaultTabComponent={TabWithoutCloseButton} - components={mainPanelComponents} - onReady={onReadyMainPanel} + components={centerPanelComponents} + onReady={onReady} theme={dockviewTheme} /> @@ -109,11 +118,7 @@ const MainPanel = memo(() => { ); }); -MainPanel.displayName = 'MainPanel'; - -const BOARDS_PANEL_ID = 'boards'; -const GALLERY_PANEL_ID = 'gallery'; -const LAYERS_PANEL_ID = 'layers'; +CenterPanel.displayName = 'CenterPanel'; const rightPanelComponents: IGridviewReactProps['components'] = { [BOARDS_PANEL_ID]: BoardsPanel, @@ -121,7 +126,7 @@ const rightPanelComponents: IGridviewReactProps['components'] = { [LAYERS_PANEL_ID]: CanvasLayersPanel, }; -export const initializeRightLayout = (api: GridviewApi) => { +export const initializeRightPanelLayout = (api: GridviewApi) => { api.addPanel({ id: GALLERY_PANEL_ID, component: GALLERY_PANEL_ID, @@ -149,31 +154,68 @@ export const initializeRightLayout = (api: GridviewApi) => { api.getPanel(BOARDS_PANEL_ID)?.api.setSize({ height: 256, width: RIGHT_PANEL_MIN_SIZE_PX }); }; -const onReadyRightPanel: IGridviewReactProps['onReady'] = (event) => { - initializeRightLayout(event.api); -}; - const RightPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeRightPanelLayout(event.api); + ctx._$rightPanelApi.set(event.api); + }, + [ctx._$rightPanelApi] + ); return ( <> ); }); RightPanel.displayName = 'RightPanel'; -export const rootComponents: IGridviewReactProps['components'] = { - [LEFT_PANEL_ID]: CanvasTabLeftPanel, - [MAIN_PANEL_ID]: MainPanel, +const leftPanelComponents: IGridviewReactProps['components'] = { + [SETTINGS_PANEL_ID]: CanvasTabLeftPanel, +}; + +export const initializeLeftPanelLayout = (api: GridviewApi) => { + api.addPanel({ + id: SETTINGS_PANEL_ID, + component: SETTINGS_PANEL_ID, + }); +}; + +const LeftPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeLeftPanelLayout(event.api); + ctx._$leftPanelApi.set(event.api); + }, + [ctx._$leftPanelApi] + ); + return ( + <> + + + ); +}); +LeftPanel.displayName = 'LeftPanel'; + +export const rootPanelComponents: IGridviewReactProps['components'] = { + [LEFT_PANEL_ID]: LeftPanel, + [MAIN_PANEL_ID]: CenterPanel, [RIGHT_PANEL_ID]: RightPanel, }; -export const initializeRootLayout = (api: GridviewApi) => { +export const initializeRootPanelLayout = (api: GridviewApi) => { api.addPanel({ id: MAIN_PANEL_ID, component: MAIN_PANEL_ID, @@ -204,22 +246,22 @@ export const initializeRootLayout = (api: GridviewApi) => { export const CanvasTabAutoLayout = memo(() => { const ref = useRef(null); - const $api = useState(() => atom(null))[0]; + const $rootPanelApi = useState(() => atom(null))[0]; const onReady = useCallback( (event) => { - $api.set(event.api); - initializeRootLayout(event.api); + $rootPanelApi.set(event.api); + initializeRootPanelLayout(event.api); }, - [$api] + [$rootPanelApi] ); - useResizeMainPanelOnFirstVisit($api, ref); + useResizeMainPanelOnFirstVisit($rootPanelApi, ref); return ( - + 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 c954118928..ec87e58d46 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,4 +1,4 @@ -import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; +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'; @@ -7,7 +7,7 @@ import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; -import { AutoLayoutProvider, PanelHotkeysLogical } from 'features/ui/layouts/auto-layout-context'; +import { AutoLayoutProvider, PanelHotkeysLogical, useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; import { dockviewTheme } from 'features/ui/styles/theme'; import { atom } from 'nanostores'; @@ -15,26 +15,27 @@ import { memo, useCallback, useRef, useState } from 'react'; import { GenerateTabLeftPanel } from './GenerateTabLeftPanel'; import { + BOARDS_PANEL_ID, + GALLERY_PANEL_ID, + LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, MAIN_PANEL_ID, + PROGRESS_PANEL_ID, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, + SETTINGS_PANEL_ID, + VIEWER_PANEL_ID, } from './shared'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; -const LAUNCHPAD_PANEL_ID = 'launchpad'; -const VIEWER_PANEL_ID = 'viewer'; -const PROGRESS_PANEL_ID = 'progress'; - -const mainPanelComponents: IDockviewReactProps['components'] = { +const centerPanelComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: GenerateLaunchpadPanel, [VIEWER_PANEL_ID]: ImageViewerPanel, [PROGRESS_PANEL_ID]: GenerationProgressPanel, }; -const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { - const { api } = event; +const initializeCenterPanelLayout = (api: DockviewApi) => { api.addPanel({ id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, @@ -60,24 +61,31 @@ const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); - - const disposables = [ - api.onWillShowOverlay((e) => { - if (e.kind === 'header_space' || e.kind === 'tab') { - return; - } - e.preventDefault(); - }), - ]; - - return () => { - disposables.forEach((disposable) => { - disposable.dispose(); - }); - }; }; -const MainPanel = memo(() => { +const CenterPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeCenterPanelLayout(event.api); + ctx._$centerPanelApi.set(event.api); + const disposables = [ + event.api.onWillShowOverlay((e) => { + if (e.kind === 'header_space' || e.kind === 'tab') { + return; + } + e.preventDefault(); + }), + ]; + + return () => { + disposables.forEach((disposable) => { + disposable.dispose(); + }); + }; + }, + [ctx._$centerPanelApi] + ); return ( <> { disableFloatingGroups={true} dndEdges={false} defaultTabComponent={TabWithoutCloseButton} - components={mainPanelComponents} - onReady={onReadyMainPanel} + components={centerPanelComponents} + onReady={onReady} theme={dockviewTheme} /> @@ -96,17 +104,14 @@ const MainPanel = memo(() => { ); }); -MainPanel.displayName = 'MainPanel'; - -const BOARDS_PANEL_ID = 'boards'; -const GALLERY_PANEL_ID = 'gallery'; +CenterPanel.displayName = 'CenterPanel'; const rightPanelComponents: IGridviewReactProps['components'] = { [BOARDS_PANEL_ID]: BoardsPanel, [GALLERY_PANEL_ID]: GalleryPanel, }; -export const initializeRightLayout = (api: GridviewApi) => { +export const initializeRightPanelLayout = (api: GridviewApi) => { api.addPanel({ id: GALLERY_PANEL_ID, component: GALLERY_PANEL_ID, @@ -125,31 +130,68 @@ export const initializeRightLayout = (api: GridviewApi) => { api.getPanel(BOARDS_PANEL_ID)?.api.setSize({ height: 256, width: RIGHT_PANEL_MIN_SIZE_PX }); }; -const onReadyRightPanel: IGridviewReactProps['onReady'] = (event) => { - initializeRightLayout(event.api); -}; - const RightPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeRightPanelLayout(event.api); + ctx._$rightPanelApi.set(event.api); + }, + [ctx._$rightPanelApi] + ); return ( <> ); }); RightPanel.displayName = 'RightPanel'; -export const rootComponents: IGridviewReactProps['components'] = { - [LEFT_PANEL_ID]: GenerateTabLeftPanel, - [MAIN_PANEL_ID]: MainPanel, +const leftPanelComponents: IGridviewReactProps['components'] = { + [SETTINGS_PANEL_ID]: GenerateTabLeftPanel, +}; + +export const initializeLeftPanelLayout = (api: GridviewApi) => { + api.addPanel({ + id: SETTINGS_PANEL_ID, + component: SETTINGS_PANEL_ID, + }); +}; + +const LeftPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeLeftPanelLayout(event.api); + ctx._$leftPanelApi.set(event.api); + }, + [ctx._$leftPanelApi] + ); + return ( + <> + + + ); +}); +LeftPanel.displayName = 'LeftPanel'; + +export const rootPanelComponents: IGridviewReactProps['components'] = { + [LEFT_PANEL_ID]: LeftPanel, + [MAIN_PANEL_ID]: CenterPanel, [RIGHT_PANEL_ID]: RightPanel, }; -export const initializeRootLayout = (api: GridviewApi) => { +export const initializeRootPanelLayout = (api: GridviewApi) => { api.addPanel({ id: MAIN_PANEL_ID, component: MAIN_PANEL_ID, @@ -180,22 +222,22 @@ export const initializeRootLayout = (api: GridviewApi) => { export const GenerateTabAutoLayout = memo(() => { const ref = useRef(null); - const $api = useState(() => atom(null))[0]; + const $rootPanelApi = useState(() => atom(null))[0]; const onReady = useCallback( (event) => { - $api.set(event.api); - initializeRootLayout(event.api); + $rootPanelApi.set(event.api); + initializeRootPanelLayout(event.api); }, - [$api] + [$rootPanelApi] ); - useResizeMainPanelOnFirstVisit($api, ref); + useResizeMainPanelOnFirstVisit($rootPanelApi, ref); return ( - + diff --git a/invokeai/frontend/web/src/features/ui/layouts/shared.ts b/invokeai/frontend/web/src/features/ui/layouts/shared.ts index 894616faf9..6c160e295e 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/shared.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/shared.ts @@ -2,5 +2,16 @@ export const LEFT_PANEL_ID = 'left'; export const MAIN_PANEL_ID = 'main'; export const RIGHT_PANEL_ID = 'right'; +export const LAUNCHPAD_PANEL_ID = 'launchpad'; +export const WORKSPACE_PANEL_ID = 'workspace'; +export const VIEWER_PANEL_ID = 'viewer'; +export const PROGRESS_PANEL_ID = 'progress'; + +export const BOARDS_PANEL_ID = 'boards'; +export const GALLERY_PANEL_ID = 'gallery'; +export const LAYERS_PANEL_ID = 'layers'; + +export const SETTINGS_PANEL_ID = 'settings'; + export const LEFT_PANEL_MIN_SIZE_PX = 420; export const RIGHT_PANEL_MIN_SIZE_PX = 420; 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 c013d5ae39..a0a57b96f2 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,4 +1,4 @@ -import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; +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'; @@ -7,34 +7,35 @@ import { GenerationProgressPanel } from 'features/gallery/components/ImageViewer import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; -import { AutoLayoutProvider, PanelHotkeysLogical } from 'features/ui/layouts/auto-layout-context'; +import { AutoLayoutProvider, PanelHotkeysLogical, useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; import { dockviewTheme } from 'features/ui/styles/theme'; import { atom } from 'nanostores'; import { memo, useCallback, useRef, useState } from 'react'; import { + BOARDS_PANEL_ID, + GALLERY_PANEL_ID, + LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, MAIN_PANEL_ID, + PROGRESS_PANEL_ID, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, + SETTINGS_PANEL_ID, + VIEWER_PANEL_ID, } from './shared'; import { UpscalingTabLeftPanel } from './UpscalingTabLeftPanel'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; -const LAUNCHPAD_PANEL_ID = 'launchpad'; -const VIEWER_PANEL_ID = 'viewer'; -const PROGRESS_PANEL_ID = 'progress'; - -const dockviewComponents: IDockviewReactProps['components'] = { +const centerComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: UpscalingLaunchpadPanel, [VIEWER_PANEL_ID]: ImageViewerPanel, [PROGRESS_PANEL_ID]: GenerationProgressPanel, }; -const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { - const { api } = event; +const initializeCenterLayout = (api: DockviewApi) => { api.addPanel({ id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, @@ -60,24 +61,30 @@ const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); - - const disposables = [ - api.onWillShowOverlay((e) => { - if (e.kind === 'header_space' || e.kind === 'tab') { - return; - } - e.preventDefault(); - }), - ]; - - return () => { - disposables.forEach((disposable) => { - disposable.dispose(); - }); - }; }; +const CenterPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeCenterLayout(event.api); + ctx._$centerPanelApi.set(event.api); + const disposables = [ + event.api.onWillShowOverlay((e) => { + if (e.kind === 'header_space' || e.kind === 'tab') { + return; + } + e.preventDefault(); + }), + ]; -const MainPanel = memo(() => { + return () => { + disposables.forEach((disposable) => { + disposable.dispose(); + }); + }; + }, + [ctx._$centerPanelApi] + ); return ( <> { disableFloatingGroups={true} dndEdges={false} defaultTabComponent={TabWithoutCloseButton} - components={dockviewComponents} - onReady={onReadyMainPanel} + components={centerComponents} + onReady={onReady} theme={dockviewTheme} /> @@ -96,17 +103,14 @@ const MainPanel = memo(() => { ); }); -MainPanel.displayName = 'MainPanel'; - -const BOARDS_PANEL_ID = 'boards'; -const GALLERY_PANEL_ID = 'gallery'; +CenterPanel.displayName = 'CenterPanel'; const rightPanelComponents: IGridviewReactProps['components'] = { [BOARDS_PANEL_ID]: BoardsPanel, [GALLERY_PANEL_ID]: GalleryPanel, }; -export const initializeRightLayout = (api: GridviewApi) => { +export const initializeRightPanelLayout = (api: GridviewApi) => { api.addPanel({ id: GALLERY_PANEL_ID, component: GALLERY_PANEL_ID, @@ -126,7 +130,7 @@ export const initializeRightLayout = (api: GridviewApi) => { }; const onReadyRightPanel: IGridviewReactProps['onReady'] = (event) => { - initializeRightLayout(event.api); + initializeRightPanelLayout(event.api); }; const RightPanel = memo(() => { @@ -143,13 +147,46 @@ const RightPanel = memo(() => { }); RightPanel.displayName = 'RightPanel'; -export const rootComponents: IGridviewReactProps['components'] = { - [LEFT_PANEL_ID]: UpscalingTabLeftPanel, - [MAIN_PANEL_ID]: MainPanel, +const leftPanelComponents: IGridviewReactProps['components'] = { + [SETTINGS_PANEL_ID]: UpscalingTabLeftPanel, +}; + +export const initializeLeftPanelLayout = (api: GridviewApi) => { + api.addPanel({ + id: SETTINGS_PANEL_ID, + component: SETTINGS_PANEL_ID, + }); +}; + +const LeftPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeLeftPanelLayout(event.api); + ctx._$leftPanelApi.set(event.api); + }, + [ctx._$leftPanelApi] + ); + return ( + <> + + + ); +}); +LeftPanel.displayName = 'LeftPanel'; + +export const rootPanelComponents: IGridviewReactProps['components'] = { + [LEFT_PANEL_ID]: LeftPanel, + [MAIN_PANEL_ID]: CenterPanel, [RIGHT_PANEL_ID]: RightPanel, }; -export const initializeRootLayout = (api: GridviewApi) => { +export const initializeRootPanelLayout = (api: GridviewApi) => { api.addPanel({ id: MAIN_PANEL_ID, component: MAIN_PANEL_ID, @@ -180,22 +217,22 @@ export const initializeRootLayout = (api: GridviewApi) => { export const UpscalingTabAutoLayout = memo(() => { const ref = useRef(null); - const $api = useState(() => atom(null))[0]; + const $rootPanelApi = useState(() => atom(null))[0]; const onReady = useCallback( (event) => { - $api.set(event.api); - initializeRootLayout(event.api); + $rootPanelApi.set(event.api); + initializeRootPanelLayout(event.api); }, - [$api] + [$rootPanelApi] ); - useResizeMainPanelOnFirstVisit($api, ref); + useResizeMainPanelOnFirstVisit($rootPanelApi, ref); return ( - + 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 de9b98aae4..966e43cf88 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,4 +1,4 @@ -import type { GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; +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'; @@ -9,35 +9,36 @@ import NodeEditor from 'features/nodes/components/NodeEditor'; import WorkflowsTabLeftPanel from 'features/nodes/components/sidePanel/WorkflowsTabLeftPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; -import { AutoLayoutProvider, PanelHotkeysLogical } from 'features/ui/layouts/auto-layout-context'; +import { AutoLayoutProvider, PanelHotkeysLogical, useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; import { TabWithoutCloseButton } from 'features/ui/layouts/TabWithoutCloseButton'; import { dockviewTheme } from 'features/ui/styles/theme'; import { atom } from 'nanostores'; import { memo, useCallback, useRef, useState } from 'react'; import { + BOARDS_PANEL_ID, + GALLERY_PANEL_ID, + LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, MAIN_PANEL_ID, + PROGRESS_PANEL_ID, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, + SETTINGS_PANEL_ID, + VIEWER_PANEL_ID, + WORKSPACE_PANEL_ID, } from './shared'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; -const LAUNCHPAD_PANEL_ID = 'launchpad'; -const WORKSPACE_PANEL_ID = 'workspace'; -const VIEWER_PANEL_ID = 'viewer'; -const PROGRESS_PANEL_ID = 'progress'; - -const dockviewComponents: IDockviewReactProps['components'] = { +const centerPanelComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: WorkflowsLaunchpadPanel, [WORKSPACE_PANEL_ID]: NodeEditor, [VIEWER_PANEL_ID]: ImageViewerPanel, [PROGRESS_PANEL_ID]: GenerationProgressPanel, }; -const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { - const { api } = event; +const initializeCenterPanelLayout = (api: DockviewApi) => { api.addPanel({ id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, @@ -72,24 +73,31 @@ const onReadyMainPanel: IDockviewReactProps['onReady'] = (event) => { }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); - - const disposables = [ - api.onWillShowOverlay((e) => { - if (e.kind === 'header_space' || e.kind === 'tab') { - return; - } - e.preventDefault(); - }), - ]; - - return () => { - disposables.forEach((disposable) => { - disposable.dispose(); - }); - }; }; -const MainPanel = memo(() => { +const CenterPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeCenterPanelLayout(event.api); + ctx._$centerPanelApi.set(event.api); + const disposables = [ + event.api.onWillShowOverlay((e) => { + if (e.kind === 'header_space' || e.kind === 'tab') { + return; + } + e.preventDefault(); + }), + ]; + + return () => { + disposables.forEach((disposable) => { + disposable.dispose(); + }); + }; + }, + [ctx._$centerPanelApi] + ); return ( <> { disableFloatingGroups={true} dndEdges={false} defaultTabComponent={TabWithoutCloseButton} - components={dockviewComponents} - onReady={onReadyMainPanel} + components={centerPanelComponents} + onReady={onReady} theme={dockviewTheme} /> @@ -108,17 +116,14 @@ const MainPanel = memo(() => { ); }); -MainPanel.displayName = 'MainPanel'; - -const BOARDS_PANEL_ID = 'boards'; -const GALLERY_PANEL_ID = 'gallery'; +CenterPanel.displayName = 'CenterPanel'; const rightPanelComponents: IGridviewReactProps['components'] = { [BOARDS_PANEL_ID]: BoardsPanel, [GALLERY_PANEL_ID]: GalleryPanel, }; -export const initializeRightLayout = (api: GridviewApi) => { +export const initializeRightPanelLayout = (api: GridviewApi) => { api.addPanel({ id: GALLERY_PANEL_ID, component: GALLERY_PANEL_ID, @@ -137,31 +142,68 @@ export const initializeRightLayout = (api: GridviewApi) => { api.getPanel(BOARDS_PANEL_ID)?.api.setSize({ height: 256, width: RIGHT_PANEL_MIN_SIZE_PX }); }; -const onReadyRightPanel: IGridviewReactProps['onReady'] = (event) => { - initializeRightLayout(event.api); -}; - const RightPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeRightPanelLayout(event.api); + ctx._$rightPanelApi.set(event.api); + }, + [ctx._$rightPanelApi] + ); return ( <> ); }); RightPanel.displayName = 'RightPanel'; -export const rootComponents: IGridviewReactProps['components'] = { - [LEFT_PANEL_ID]: WorkflowsTabLeftPanel, - [MAIN_PANEL_ID]: MainPanel, +const leftPanelComponents: IGridviewReactProps['components'] = { + [SETTINGS_PANEL_ID]: WorkflowsTabLeftPanel, +}; + +export const initializeLeftPanelLayout = (api: GridviewApi) => { + api.addPanel({ + id: SETTINGS_PANEL_ID, + component: SETTINGS_PANEL_ID, + }); +}; + +const LeftPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const onReady = useCallback( + (event) => { + initializeLeftPanelLayout(event.api); + ctx._$leftPanelApi.set(event.api); + }, + [ctx._$leftPanelApi] + ); + return ( + <> + + + ); +}); +LeftPanel.displayName = 'LeftPanel'; + +export const rootPanelComponents: IGridviewReactProps['components'] = { + [LEFT_PANEL_ID]: LeftPanel, + [MAIN_PANEL_ID]: CenterPanel, [RIGHT_PANEL_ID]: RightPanel, }; -export const initializeRootLayout = (api: GridviewApi) => { +export const initializeRootPanelLayout = (api: GridviewApi) => { api.addPanel({ id: MAIN_PANEL_ID, component: MAIN_PANEL_ID, @@ -192,22 +234,22 @@ export const initializeRootLayout = (api: GridviewApi) => { export const WorkflowsTabAutoLayout = memo(() => { const ref = useRef(null); - const $api = useState(() => atom(null))[0]; + const $rootPanelApi = useState(() => atom(null))[0]; const onReady = useCallback( (event) => { - $api.set(event.api); - initializeRootLayout(event.api); + $rootPanelApi.set(event.api); + initializeRootPanelLayout(event.api); }, - [$api] + [$rootPanelApi] ); - useResizeMainPanelOnFirstVisit($api, ref); + useResizeMainPanelOnFirstVisit($rootPanelApi, ref); return ( - + From b06f76cdb693d5a323d60f65ff144032d7dfa598 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:32:58 +1000 Subject: [PATCH 126/210] fix(ui): unable to resize prompt box bc negative prompt button is over the handle --- .../features/parameters/components/Core/ParamPositivePrompt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx index 808ae55a1e..2c3d178697 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx @@ -99,7 +99,7 @@ export const ParamPositivePrompt = memo(() => { paddingTop={0} paddingBottom={3} resize="vertical" - minH={28} + minH={32} /> From 041023df53bc088b8a921e9e9b13f3e2f91d8648 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:34:55 +1000 Subject: [PATCH 127/210] feat(ui): tweak vertical tab bar layout --- .../frontend/web/src/features/ui/components/AppContent.tsx | 6 ++---- .../frontend/web/src/features/ui/components/TabButton.tsx | 3 +-- .../web/src/features/ui/components/VerticalNavBar.tsx | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index f810d8e1e4..7952177192 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -1,7 +1,7 @@ import 'dockview/dist/styles/dockview.css'; import 'features/ui/styles/dockview-theme-invoke.css'; -import { TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { useDndMonitor } from 'features/dnd/useDndMonitor'; import { VerticalNavBar } from 'features/ui/components/VerticalNavBar'; @@ -22,9 +22,7 @@ export const AppContent = memo(() => { return ( - - - + diff --git a/invokeai/frontend/web/src/features/ui/components/TabButton.tsx b/invokeai/frontend/web/src/features/ui/components/TabButton.tsx index 6bcc351618..a112ac9fb5 100644 --- a/invokeai/frontend/web/src/features/ui/components/TabButton.tsx +++ b/invokeai/frontend/web/src/features/ui/components/TabButton.tsx @@ -1,5 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { IconButton, Tab, Tooltip } from '@invoke-ai/ui-library'; +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; @@ -26,7 +26,6 @@ export const TabButton = memo(({ tab, icon, label }: { tab: TabName; icon: React return ( { return ( - + } label="Generate" /> From 3984b341e180f3259f2d9dca844e93346f129580 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:35:14 +1000 Subject: [PATCH 128/210] fix(ui): don't use layers when generating on generate tab --- .../nodes/util/graph/generation/buildChatGPT4oGraph.ts | 6 ++++-- .../nodes/util/graph/generation/buildCogView4Graph.ts | 6 ++++-- .../features/nodes/util/graph/generation/buildFLUXGraph.ts | 6 ++++-- .../nodes/util/graph/generation/buildImagen3Graph.ts | 6 ++++-- .../nodes/util/graph/generation/buildImagen4Graph.ts | 6 ++++-- .../features/nodes/util/graph/generation/buildSD1Graph.ts | 6 ++++-- .../features/nodes/util/graph/generation/buildSD3Graph.ts | 6 ++++-- .../features/nodes/util/graph/generation/buildSDXLGraph.ts | 6 ++++-- .../nodes/util/graph/generation/getGenerationMode.ts | 5 +++-- 9 files changed, 35 insertions(+), 18 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index 2f389d059a..21ad0fe8e4 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -12,6 +12,7 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -20,9 +21,10 @@ const log = logger('system'); export const buildChatGPT4oGraph = async ( state: RootState, - manager?: CanvasManager | null + manager: CanvasManager | null ): Promise => { - const generationMode = await getGenerationMode(manager); + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); if (generationMode !== 'txt2img' && generationMode !== 'img2img') { throw new UnsupportedGenerationModeError(t('toast.chatGPT4oIncompatibleGenerationMode')); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts index 4e369bc3bf..d2b27dd7cc 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildCogView4Graph.ts @@ -19,6 +19,7 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { Invocation } from 'services/api/types'; import { isNonRefinerMainModelConfig } from 'services/api/types'; import type { Equals } from 'tsafe'; @@ -28,9 +29,10 @@ const log = logger('system'); export const buildCogView4Graph = async ( state: RootState, - manager?: CanvasManager | null + manager: CanvasManager | null ): Promise => { - const generationMode = await getGenerationMode(manager); + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); log.debug({ generationMode }, 'Building CogView4 graph'); const params = selectParamsSlice(state); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts index fd97936f67..2a012bd85a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildFLUXGraph.ts @@ -27,6 +27,7 @@ import { type ImageOutputNodes, UnsupportedGenerationModeError, } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { t } from 'i18next'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; @@ -37,8 +38,9 @@ import { addIPAdapters } from './addIPAdapters'; const log = logger('system'); -export const buildFLUXGraph = async (state: RootState, manager?: CanvasManager | null): Promise => { - const generationMode = await getGenerationMode(manager); +export const buildFLUXGraph = async (state: RootState, manager: CanvasManager | null): Promise => { + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); log.debug({ generationMode }, 'Building FLUX graph'); const params = selectParamsSlice(state); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts index b200d6b915..15c478798f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen3Graph.ts @@ -10,6 +10,7 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -18,9 +19,10 @@ const log = logger('system'); export const buildImagen3Graph = async ( state: RootState, - manager?: CanvasManager | null + manager: CanvasManager | null ): Promise => { - const generationMode = await getGenerationMode(manager); + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); if (generationMode !== 'txt2img') { throw new UnsupportedGenerationModeError(t('toast.imagenIncompatibleGenerationMode', { model: 'Imagen3' })); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts index 5138041c9e..b83c67f21c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildImagen4Graph.ts @@ -10,6 +10,7 @@ import { getGenerationMode } from 'features/nodes/util/graph/generation/getGener import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import { type GraphBuilderReturn, UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { t } from 'i18next'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -18,9 +19,10 @@ const log = logger('system'); export const buildImagen4Graph = async ( state: RootState, - manager?: CanvasManager | null + manager: CanvasManager | null ): Promise => { - const generationMode = await getGenerationMode(manager); + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); if (generationMode !== 'txt2img') { throw new UnsupportedGenerationModeError(t('toast.imagenIncompatibleGenerationMode', { model: 'Imagen4' })); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts index 8ba379ec43..af5c0d5601 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts @@ -24,6 +24,7 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -32,8 +33,9 @@ import { addRegions } from './addRegions'; const log = logger('system'); -export const buildSD1Graph = async (state: RootState, manager?: CanvasManager | null): Promise => { - const generationMode = await getGenerationMode(manager); +export const buildSD1Graph = async (state: RootState, manager: CanvasManager | null): Promise => { + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); log.debug({ generationMode }, 'Building SD1/SD2 graph'); const params = selectParamsSlice(state); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts index 3230d7132c..a1539f7c9f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD3Graph.ts @@ -18,14 +18,16 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; const log = logger('system'); -export const buildSD3Graph = async (state: RootState, manager?: CanvasManager | null): Promise => { - const generationMode = await getGenerationMode(manager); +export const buildSD3Graph = async (state: RootState, manager: CanvasManager | null): Promise => { + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); log.debug({ generationMode }, 'Building SD3 graph'); const model = selectMainModelConfig(state); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts index f091fa4411..13534f61e1 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSDXLGraph.ts @@ -24,6 +24,7 @@ import { selectPresetModifiedPrompts, } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { Invocation } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -32,8 +33,9 @@ import { addRegions } from './addRegions'; const log = logger('system'); -export const buildSDXLGraph = async (state: RootState, manager?: CanvasManager | null): Promise => { - const generationMode = await getGenerationMode(manager); +export const buildSDXLGraph = async (state: RootState, manager: CanvasManager | null): Promise => { + const tab = selectActiveTab(state); + const generationMode = await getGenerationMode(manager, tab); log.debug({ generationMode }, 'Building SDXL graph'); const model = selectMainModelConfig(state); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts index b458088d5a..5f3c48637a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/getGenerationMode.ts @@ -1,8 +1,9 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { GenerationMode } from 'features/controlLayers/store/types'; +import type { TabName } from 'features/ui/store/uiTypes'; -export const getGenerationMode = async (manager?: CanvasManager | null): Promise => { - if (!manager) { +export const getGenerationMode = async (manager: CanvasManager | null, tab: TabName): Promise => { + if (!manager || tab === 'generate') { return 'txt2img'; } return await manager.compositor.getGenerationMode(); From 3264188ffd382721a2c8589ab3afe247e71dce1d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:41:49 +1000 Subject: [PATCH 129/210] fix(ui): launchpad layouts --- .../components/SimpleSession/CanvasLaunchpadPanel.tsx | 2 +- .../components/SimpleSession/GenerateLaunchpadPanel.tsx | 4 ++-- .../components/SimpleSession/UpscalingLaunchpadPanel.tsx | 4 ++-- .../components/SimpleSession/WorkflowsLaunchpadPanel.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx index 735f07d1af..2af9fb2056 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx @@ -10,7 +10,7 @@ import { LaunchpadUseALayoutImageButton } from './LaunchpadUseALayoutImageButton export const CanvasLaunchpadPanel = memo(() => { return ( - + Edit and refine on Canvas. diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx index 969b2669dd..d43ab03fa9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel.tsx @@ -14,8 +14,8 @@ export const GenerateLaunchpadPanel = memo(() => { }, [dispatch]); return ( - - + + Generate images from text prompts. diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel.tsx index 98db034c3a..f9945f0876 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/UpscalingLaunchpadPanel.tsx @@ -3,8 +3,8 @@ import { memo } from 'react'; export const UpscalingLaunchpadPanel = memo(() => { return ( - - + + Upscale and add detail. diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel.tsx index 0a2ee4cfb1..03c4ea1b34 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/WorkflowsLaunchpadPanel.tsx @@ -3,8 +3,8 @@ import { memo } from 'react'; export const WorkflowsLaunchpadPanel = memo(() => { return ( - - + + Go deep with Workflows. From a30933b09c3a801dd262e2e7cff4430960689d3b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:01:19 +1000 Subject: [PATCH 130/210] feat(ui): clean up image view components & code --- .../SimpleSession/SimpleSession.tsx | 35 ------ .../ImageViewer/CurrentImageButtons.tsx | 32 ++---- .../ImageViewer/CurrentImagePreview.tsx | 19 +--- .../ImageViewer/CurrentImagePreview2.tsx | 105 ------------------ .../ImageViewer/GenerationProgressPanel.tsx | 2 +- .../components/ImageViewer/ImageViewer.tsx | 22 +++- .../components/ImageViewer/ImageViewer2.tsx | 42 ------- .../ImageViewer/ImageViewerPanel.tsx | 4 +- .../components/ImageViewer/ProgressImage.tsx | 45 +++++--- .../components/ImageViewer/ProgressImage2.tsx | 56 ---------- .../ToggleMetadataViewerButton.tsx | 9 +- .../components/ImageViewer/ViewerToolbar.tsx | 30 ++--- .../components/ImageViewer/ViewerToolbar2.tsx | 18 --- .../features/gallery/hooks/useImageActions.ts | 43 +++++-- .../ui/components/MainPanelContent.tsx | 39 ------- 15 files changed, 110 insertions(+), 391 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar2.tsx delete mode 100644 invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx deleted file mode 100644 index 693fa27e1f..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/SimpleSession.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; -import { GenerateLaunchpadPanel } from 'features/controlLayers/components/SimpleSession/GenerateLaunchpadPanel'; -import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; -import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; -import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; -import { memo } from 'react'; - -export const SimpleSession = memo(() => { - return ( - - - Launchpad - Viewer - Generation Progress - - - - - - - - - - - - - - - - - - - ); -}); -SimpleSession.displayName = 'SimpleSession'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 9bef56ccd2..9332a2bfaf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -22,25 +22,13 @@ import { PiRulerBold, } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; -const CurrentImageButtons = () => { +export const CurrentImageButtons = memo(() => { const lastSelectedImage = useAppSelector(selectLastSelectedImage); const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken); - - if (!imageDTO) { - return null; - } - - return ; -}; - -export default memo(CurrentImageButtons); - -const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { const { t } = useTranslation(); const hasTemplates = useStore($hasTemplates); - const imageActions = useImageActions(imageDTO); + const imageActions = useImageActions(imageDTO ?? null); const isStaging = useAppSelector(selectIsStaging); const isUpscalingEnabled = useFeatureStatus('upscaling'); @@ -65,7 +53,7 @@ const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) = icon={} tooltip={`${t('nodes.loadWorkflow')} (W)`} aria-label={`${t('nodes.loadWorkflow')} (W)`} - isDisabled={!imageActions.hasWorkflow || !hasTemplates} + isDisabled={!imageDTO || !imageActions.hasWorkflow || !hasTemplates} variant="link" alignSelf="stretch" onClick={imageActions.loadWorkflow} @@ -74,7 +62,7 @@ const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) = icon={} tooltip={`${t('parameters.remixImage')} (R)`} aria-label={`${t('parameters.remixImage')} (R)`} - isDisabled={!imageActions.hasMetadata} + isDisabled={!imageDTO || !imageActions.hasMetadata} variant="link" alignSelf="stretch" onClick={imageActions.remix} @@ -83,7 +71,7 @@ const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) = icon={} tooltip={`${t('parameters.usePrompt')} (P)`} aria-label={`${t('parameters.usePrompt')} (P)`} - isDisabled={!imageActions.hasPrompts} + isDisabled={!imageDTO || !imageActions.hasPrompts} variant="link" alignSelf="stretch" onClick={imageActions.recallPrompts} @@ -92,7 +80,7 @@ const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) = icon={} tooltip={`${t('parameters.useSeed')} (S)`} aria-label={`${t('parameters.useSeed')} (S)`} - isDisabled={!imageActions.hasSeed} + isDisabled={!imageDTO || !imageActions.hasSeed} variant="link" alignSelf="stretch" onClick={imageActions.recallSeed} @@ -104,13 +92,13 @@ const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) = variant="link" alignSelf="stretch" onClick={imageActions.recallSize} - isDisabled={isStaging} + isDisabled={!imageDTO || isStaging} /> } tooltip={`${t('parameters.useAll')} (A)`} aria-label={`${t('parameters.useAll')} (A)`} - isDisabled={!imageActions.hasMetadata} + isDisabled={!imageDTO || !imageActions.hasMetadata} variant="link" alignSelf="stretch" onClick={imageActions.recallAll} @@ -120,9 +108,9 @@ const CurrentImageButtonsContent = memo(({ imageDTO }: { imageDTO: ImageDTO }) = - + ); }); -CurrentImageButtonsContent.displayName = 'CurrentImageButtonsContent'; +CurrentImageButtons.displayName = 'CurrentImageButtons'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index b2f5158b7a..7b153a74cf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -1,21 +1,18 @@ import { Box, Flex } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; -import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; +import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; -import { $hasLastProgressImage } from 'services/events/stores'; import { NoContentForViewer } from './NoContentForViewer'; -import ProgressImage from './ProgressImage'; -const CurrentImagePreview = ({ imageDTO }: { imageDTO?: ImageDTO }) => { +export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); // Show and hide the next/prev buttons on mouse move @@ -79,18 +76,10 @@ const CurrentImagePreview = ({ imageDTO }: { imageDTO?: ImageDTO }) => { ); -}; - -export default memo(CurrentImagePreview); +}); +CurrentImagePreview.displayName = 'CurrentImagePreview'; const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { - const hasProgressImage = useStore($hasLastProgressImage); - const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); - - if (hasProgressImage && shouldShowProgressInViewer) { - return ; - } - if (!imageDTO) { return ; } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx deleted file mode 100644 index 7b153a74cf..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview2.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Box, Flex } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; -import { DndImage } from 'features/dnd/DndImage'; -import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; -import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors'; -import type { AnimationProps } from 'framer-motion'; -import { AnimatePresence, motion } from 'framer-motion'; -import { memo, useCallback, useRef, useState } from 'react'; -import type { ImageDTO } from 'services/api/types'; - -import { NoContentForViewer } from './NoContentForViewer'; - -export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { - const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); - - // Show and hide the next/prev buttons on mouse move - const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); - const timeoutId = useRef(0); - const onMouseOver = useCallback(() => { - setShouldShowNextPrevButtons(true); - window.clearTimeout(timeoutId.current); - }, []); - const onMouseOut = useCallback(() => { - timeoutId.current = window.setTimeout(() => { - setShouldShowNextPrevButtons(false); - }, 500); - }, []); - - return ( - - - - - - {shouldShowImageDetails && imageDTO && ( - - - - )} - - {shouldShowNextPrevButtons && imageDTO && ( - - - - )} - - - ); -}); -CurrentImagePreview.displayName = 'CurrentImagePreview'; - -const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { - if (!imageDTO) { - return ; - } - - return ( - - - - ); -}); -ImageContent.displayName = 'ImageContent'; - -const initial: AnimationProps['initial'] = { - opacity: 0, -}; -const animateArrows: AnimationProps['animate'] = { - opacity: 1, - transition: { duration: 0.07 }, -}; -const exit: AnimationProps['exit'] = { - opacity: 0, - transition: { duration: 0.07 }, -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx index bfcda19650..23cce5ce90 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx @@ -1,5 +1,5 @@ import { Flex } from '@invoke-ai/ui-library'; -import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; +import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage'; import { memo } from 'react'; export const GenerationProgressPanel = memo(() => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 6f27298eb6..6ca2e1b8c5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -1,12 +1,32 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common'; -import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview'; +import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview'; import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison'; import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; import { memo } from 'react'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +// type Props = { +// closeButton?: ReactNode; +// }; + +// const useFocusRegionOptions = { +// focusOnMount: true, +// }; + +// const FOCUS_REGION_STYLES: SystemStyleObject = { +// display: 'flex', +// width: 'full', +// height: 'full', +// position: 'absolute', +// flexDirection: 'column', +// inset: 0, +// alignItems: 'center', +// justifyContent: 'center', +// overflow: 'hidden', +// }; + export const ImageViewer = memo(() => { const lastSelectedImageName = useAppSelector(selectLastSelectedImageName); const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx deleted file mode 100644 index 510a974a25..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer2.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { skipToken } from '@reduxjs/toolkit/query'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common'; -import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview2'; -import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison'; -import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; -import { memo } from 'react'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; - -// type Props = { -// closeButton?: ReactNode; -// }; - -// const useFocusRegionOptions = { -// focusOnMount: true, -// }; - -// const FOCUS_REGION_STYLES: SystemStyleObject = { -// display: 'flex', -// width: 'full', -// height: 'full', -// position: 'absolute', -// flexDirection: 'column', -// inset: 0, -// alignItems: 'center', -// justifyContent: 'center', -// overflow: 'hidden', -// }; - -export const ImageViewer = memo(() => { - const lastSelectedImageName = useAppSelector(selectLastSelectedImageName); - const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken); - const comparisonImageDTO = useAppSelector(selectImageToCompare); - - if (lastSelectedImageDTO && comparisonImageDTO) { - return ; - } - - return ; -}); - -ImageViewer.displayName = 'ImageViewer'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx index 0fd32cfa54..765a6e0909 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx @@ -1,6 +1,6 @@ import { Divider, Flex } from '@invoke-ai/ui-library'; -import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer2'; -import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2'; +import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; +import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar'; import { memo } from 'react'; export const ImageViewerPanel = memo(() => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx index 03453e4c56..850ebc63e1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx @@ -1,10 +1,12 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Image } from '@invoke-ai/ui-library'; +import { Flex, Image } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { selectSystemSlice } from 'features/system/store/systemSlice'; import { memo, useMemo } from 'react'; +import { PiPulseBold } from 'react-icons/pi'; import { $lastProgressImage } from 'services/events/stores'; const selectShouldAntialiasProgressImage = createSelector( @@ -12,7 +14,7 @@ const selectShouldAntialiasProgressImage = createSelector( (system) => system.shouldAntialiasProgressImage ); -const CurrentImagePreview = () => { +export const ProgressImage = memo(() => { const progressImage = useStore($lastProgressImage); const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage); @@ -24,24 +26,31 @@ const CurrentImagePreview = () => { ); if (!progressImage) { - return null; + return ( + + + + ); } return ( - + + + ); -}; +}); -export default memo(CurrentImagePreview); +ProgressImage.displayName = 'ProgressImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx deleted file mode 100644 index 850ebc63e1..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Flex, Image } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppSelector } from 'app/store/storeHooks'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { selectSystemSlice } from 'features/system/store/systemSlice'; -import { memo, useMemo } from 'react'; -import { PiPulseBold } from 'react-icons/pi'; -import { $lastProgressImage } from 'services/events/stores'; - -const selectShouldAntialiasProgressImage = createSelector( - selectSystemSlice, - (system) => system.shouldAntialiasProgressImage -); - -export const ProgressImage = memo(() => { - const progressImage = useStore($lastProgressImage); - const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage); - - const sx = useMemo( - () => ({ - imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated', - }), - [shouldAntialiasProgressImage] - ); - - if (!progressImage) { - return ( - - - - ); - } - - return ( - - - - ); -}); - -ProgressImage.displayName = 'ProgressImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx index 5d552e57d9..e40cb510c6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx @@ -14,16 +14,14 @@ export const ToggleMetadataViewerButton = memo(() => { const imageDTO = useAppSelector(selectLastSelectedImage); const { t } = useTranslation(); - const toggleMetadataViewer = useCallback( - () => dispatch(setShouldShowImageDetails(!shouldShowImageDetails)), - [dispatch, shouldShowImageDetails] - ); + const toggleMetadataViewer = useCallback(() => { + dispatch(setShouldShowImageDetails(!shouldShowImageDetails)); + }, [dispatch, shouldShowImageDetails]); useRegisteredHotkeys({ id: 'toggleMetadata', category: 'viewer', callback: toggleMetadataViewer, - options: { enabled: Boolean(imageDTO) }, dependencies: [imageDTO, shouldShowImageDetails], }); @@ -33,7 +31,6 @@ export const ToggleMetadataViewerButton = memo(() => { tooltip={`${t('parameters.info')} (I)`} aria-label={`${t('parameters.info')} (I)`} onClick={toggleMetadataViewer} - isDisabled={!imageDTO} variant="link" alignSelf="stretch" colorScheme={shouldShowImageDetails ? 'invokeBlue' : 'base'} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx index e4bdf895f4..1c5a67bae8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx @@ -1,32 +1,16 @@ -import { Flex } from '@invoke-ai/ui-library'; +import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton'; -import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton'; -import type { ReactNode } from 'react'; import { memo } from 'react'; -import CurrentImageButtons from './CurrentImageButtons'; +import { CurrentImageButtons } from './CurrentImageButtons'; -type Props = { - closeButton?: ReactNode; -}; - -export const ViewerToolbar = memo(({ closeButton }: Props) => { +export const ViewerToolbar = memo(() => { return ( - - - - - - - - + + + - - - - {closeButton} - - + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar2.tsx deleted file mode 100644 index f8dc34d654..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar2.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; -import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton'; -import { memo } from 'react'; - -import CurrentImageButtons from './CurrentImageButtons'; - -export const ViewerToolbar = memo(() => { - return ( - - - - - - - ); -}); - -ViewerToolbar.displayName = 'ViewerToolbar'; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts index cb37c2c11c..90cc41e689 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts @@ -24,12 +24,12 @@ import { useTranslation } from 'react-i18next'; import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata'; import type { ImageDTO } from 'services/api/types'; -export const useImageActions = (imageDTO: ImageDTO) => { +export const useImageActions = (imageDTO: ImageDTO | null) => { const { dispatch, getState } = useAppStore(); const { t } = useTranslation(); const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId); const isStaging = useAppSelector(selectIsStaging); - const { metadata } = useDebouncedMetadata(imageDTO.image_name); + const { metadata } = useDebouncedMetadata(imageDTO?.image_name ?? null); const [hasMetadata, setHasMetadata] = useState(false); const [hasSeed, setHasSeed] = useState(false); const [hasPrompts, setHasPrompts] = useState(false); @@ -79,15 +79,21 @@ export const useImageActions = (imageDTO: ImageDTO) => { }, [dispatch, activeStylePresetId, t]); const recallAll = useCallback(() => { + if (!imageDTO) { + return; + } if (!metadata) { return; } const activeTabName = selectActiveTab(getState()); parseAndRecallAllMetadata(metadata, activeTabName === 'canvas', isStaging ? ['width', 'height'] : []); clearStylePreset(); - }, [metadata, getState, isStaging, clearStylePreset]); + }, [imageDTO, metadata, getState, isStaging, clearStylePreset]); const remix = useCallback(() => { + if (!imageDTO) { + return; + } if (!metadata) { return; } @@ -95,9 +101,12 @@ export const useImageActions = (imageDTO: ImageDTO) => { // Recalls all metadata parameters except seed parseAndRecallAllMetadata(metadata, activeTabName === 'canvas', ['seed']); clearStylePreset(); - }, [metadata, getState, clearStylePreset]); + }, [imageDTO, metadata, getState, clearStylePreset]); const recallSeed = useCallback(() => { + if (!imageDTO) { + return; + } if (!metadata) { return; } @@ -109,17 +118,23 @@ export const useImageActions = (imageDTO: ImageDTO) => { .catch(() => { // no-op, the toast will show the error }); - }, [metadata]); + }, [imageDTO, metadata]); const recallPrompts = useCallback(() => { + if (!imageDTO) { + return; + } if (!metadata) { return; } parseAndRecallPrompts(metadata); clearStylePreset(); - }, [metadata, clearStylePreset]); + }, [imageDTO, metadata, clearStylePreset]); const createAsPreset = useCallback(async () => { + if (!imageDTO) { + return; + } if (!metadata) { return; } @@ -153,14 +168,20 @@ export const useImageActions = (imageDTO: ImageDTO) => { const loadWorkflowWithDialog = useLoadWorkflowWithDialog(); const loadWorkflowFromImage = useCallback(() => { + if (!imageDTO) { + return; + } if (!imageDTO.has_workflow || !hasTemplates) { return; } loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name }); - }, [hasTemplates, imageDTO.has_workflow, imageDTO.image_name, loadWorkflowWithDialog]); + }, [hasTemplates, imageDTO, loadWorkflowWithDialog]); const recallSize = useCallback(() => { + if (!imageDTO) { + return; + } if (isStaging) { return; } @@ -168,10 +189,16 @@ export const useImageActions = (imageDTO: ImageDTO) => { }, [imageDTO, isStaging]); const upscale = useCallback(() => { + if (!imageDTO) { + return; + } dispatch(adHocPostProcessingRequested({ imageDTO })); }, [dispatch, imageDTO]); const _delete = useCallback(() => { + if (!imageDTO) { + return; + } deleteImageModal.delete([imageDTO]); }, [deleteImageModal, imageDTO]); @@ -185,7 +212,7 @@ export const useImageActions = (imageDTO: ImageDTO) => { recallPrompts, createAsPreset, loadWorkflow: loadWorkflowFromImage, - hasWorkflow: imageDTO.has_workflow, + hasWorkflow: imageDTO?.has_workflow ?? false, recallSize, upscale, delete: _delete, diff --git a/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx b/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx deleted file mode 100644 index ec5e41deff..0000000000 --- a/invokeai/frontend/web/src/features/ui/components/MainPanelContent.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useAppSelector } from 'app/store/storeHooks'; -import { AdvancedSession } from 'features/controlLayers/components/AdvancedSession/AdvancedSession'; -import { SimpleSession } from 'features/controlLayers/components/SimpleSession/SimpleSession'; -import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; -import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab'; -import QueueTab from 'features/ui/components/tabs/QueueTab'; -import { WorkflowsMainPanel } from 'features/ui/components/tabs/WorkflowsTabContent'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { memo } from 'react'; -import type { Equals } from 'tsafe'; -import { assert } from 'tsafe'; - -export const MainPanelContent = memo(() => { - const tab = useAppSelector(selectActiveTab); - const canvasId = useAppSelector(selectCanvasSessionId); - - if (tab === 'generate') { - return ; - } - if (tab === 'canvas') { - return ; - } - if (tab === 'upscaling') { - return ; - } - if (tab === 'workflows') { - return ; - } - if (tab === 'models') { - return ; - } - if (tab === 'queue') { - return ; - } - - assert>(false); -}); -MainPanelContent.displayName = 'MainPanelContent'; From 81341deb46d9364c2dad01ad828e2b1a566b13c6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:39:31 +1000 Subject: [PATCH 131/210] feat(ui): mini metadata viewer --- .../ImageViewer/CurrentImagePreview.tsx | 12 +-- .../ImageViewer/ImageMetadataMini.tsx | 92 +++++++++++++++++++ .../api/hooks/useDebouncedMetadata.ts | 5 +- 3 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageMetadataMini.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 7b153a74cf..687cddd7e7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -10,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; +import { ImageMetadataMini } from './ImageMetadataMini'; import { NoContentForViewer } from './NoContentForViewer'; export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { @@ -39,16 +40,9 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) position="relative" > - + + {imageDTO && } {shouldShowImageDetails && imageDTO && ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageMetadataMini.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageMetadataMini.tsx new file mode 100644 index 0000000000..b9cdac48e3 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageMetadataMini.tsx @@ -0,0 +1,92 @@ +import { Alert, AlertIcon, AlertTitle, Grid, GridItem, Text } from '@invoke-ai/ui-library'; +import { useMetadataItem } from 'features/metadata/hooks/useMetadataItem'; +import { handlers } from 'features/metadata/util/handlers'; +import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata'; + +export const ImageMetadataMini = ({ imageName }: { imageName: string }) => { + const { metadata, isLoading } = useDebouncedMetadata(imageName); + const createdBy = useMetadataItem(metadata, handlers.createdBy); + const positivePrompt = useMetadataItem(metadata, handlers.positivePrompt); + const negativePrompt = useMetadataItem(metadata, handlers.negativePrompt); + const seed = useMetadataItem(metadata, handlers.seed); + const model = useMetadataItem(metadata, handlers.model); + const strength = useMetadataItem(metadata, handlers.strength); + + if (isLoading) { + return ( + + + Loading metadata... + + ); + } + if ( + !createdBy.valueOrNull && + !positivePrompt.valueOrNull && + !negativePrompt.valueOrNull && + !seed.valueOrNull && + !model.valueOrNull && + !strength.valueOrNull + ) { + return ( + + No metadata found + + ); + } + return ( + + + {createdBy.valueOrNull && ( + <> + + {createdBy.label}: + + {createdBy.renderedValue} + + )} + {positivePrompt.valueOrNull && ( + <> + + {positivePrompt.label}: + + {positivePrompt.renderedValue} + + )} + {negativePrompt.valueOrNull && ( + <> + + {negativePrompt.label}: + + {negativePrompt.renderedValue} + + )} + {model.valueOrNull !== null && ( + <> + + {model.label}: + + {model.renderedValue} + + )} + {strength.valueOrNull !== null && ( + <> + + {strength.label}: + + {strength.renderedValue} + + )} + {seed.valueOrNull !== null && ( + <> + + {seed.label}: + + {seed.renderedValue} + + )} + + + ); +}; +ImageMetadataMini.displayName = 'ImageMetadataMini'; diff --git a/invokeai/frontend/web/src/services/api/hooks/useDebouncedMetadata.ts b/invokeai/frontend/web/src/services/api/hooks/useDebouncedMetadata.ts index 81e36c95a7..50403ff869 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useDebouncedMetadata.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useDebouncedMetadata.ts @@ -8,8 +8,7 @@ export const useDebouncedMetadata = (imageName?: string | null) => { const metadataFetchDebounce = useAppSelector(selectMetadataFetchDebounce); const [debouncedImageName] = useDebounce(imageName, metadataFetchDebounce); + const { data, isFetching } = useGetImageMetadataQuery(debouncedImageName ?? skipToken); - const { data: metadata, isLoading } = useGetImageMetadataQuery(debouncedImageName ?? skipToken); - - return { metadata, isLoading }; + return { metadata: data, isLoading: isFetching || imageName !== debouncedImageName }; }; From 8d0fe5522b6e8afb9e52c303113d9b0a1c696c3e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:50:00 +1000 Subject: [PATCH 132/210] feat(ui): no model error state for ref images --- .../components/RefImage/RefImage.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx index 2f66b80390..e4aadb093a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx @@ -2,6 +2,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Divider, Flex, + Icon, IconButton, Image, Popover, @@ -25,7 +26,7 @@ import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageId import { isIPAdapterConfig } from 'features/controlLayers/store/types'; import { round } from 'lodash-es'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { PiImageBold } from 'react-icons/pi'; +import { PiExclamationMarkBold, PiImageBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; // There is some awkwardness here with closing the popover when clicking outside of it, related to Chakra's @@ -94,6 +95,10 @@ const baseSx: SystemStyleObject = { '&[data-is-open="true"]': { opacity: 1, }, + '&[data-is-error="true"]': { + borderColor: 'error.500', + borderWidth: 2, + }, }; const weightDisplaySx: SystemStyleObject = { @@ -182,6 +187,7 @@ const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => { flexShrink={0} sx={sx} data-is-open={disclosure.isOpen} + data-is-error={!entity.config.model} id={getRefImagePopoverTriggerId(id)} role="button" onClick={disclosure.toggle} @@ -213,6 +219,18 @@ const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => { )} + {!entity.config.model && ( + + )} ); From 399d6e7bcee360f36e016261c9c4c74c40eae86b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 20 Jun 2025 18:13:18 +1000 Subject: [PATCH 133/210] chore: bump version to v6.0.0a4 --- 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 9a0311fe55..6656473abc 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "6.0.0a3" +__version__ = "6.0.0a4" From e10afe3026974a9edab2006b84000ffd35dfb272 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 11:34:26 +1000 Subject: [PATCH 134/210] feat(ui): re-implement multiple auto-switch modes --- .../components/SimpleSession/context.tsx | 93 +++++++++++++------ .../StagingAreaToolbarMenuAutoSwitch.tsx | 15 ++- 2 files changed, 75 insertions(+), 33 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 3f55bfe252..45472b9ffd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppStore } from 'app/store/nanostores/store'; +import { buildZodTypeGuard } from 'common/util/zodUtils'; import { getOutputImageName } from 'features/controlLayers/components/SimpleSession/shared'; import type { ProgressImage } from 'features/nodes/types/common'; import type { Atom, MapStore, StoreValue, WritableAtom } from 'nanostores'; @@ -13,6 +14,11 @@ 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'; +import { z } from 'zod'; + +const zAutoSwitchMode = z.enum(['off', 'switch_on_start', 'switch_on_finish']); +export const isAutoSwitchMode = buildZodTypeGuard(zAutoSwitchMode); +export type AutoSwitchMode = z.infer; export type ProgressData = { itemId: number; @@ -91,7 +97,7 @@ type CanvasSessionContextValue = { $selectedItem: Atom; $selectedItemIndex: Atom; $selectedItemOutputImageDTO: Atom; - $autoSwitch: WritableAtom; + $autoSwitch: WritableAtom; selectNext: () => void; selectPrev: () => void; selectFirst: () => void; @@ -116,8 +122,17 @@ export const CanvasSessionContextProvider = memo( const store = useAppStore(); const socket = useStore($socket); + + /** + * Track the last completed item. Used to implement autoswitch. + */ const $lastCompletedItemId = useState(() => atom(null))[0]; + /** + * Track the last started item. Used to implement autoswitch. + */ + const $lastStartedItemId = 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. @@ -127,7 +142,7 @@ export const CanvasSessionContextProvider = memo( /** * Whether auto-switch is enabled. */ - const $autoSwitch = useState(() => atom(true))[0]; + const $autoSwitch = useState(() => atom('switch_on_start'))[0]; /** * An internal flag used to work around race conditions with auto-switch switching to queue items before their @@ -277,12 +292,12 @@ export const CanvasSessionContextProvider = memo( imageLoaded: true, }); } - if ($lastCompletedItemId.get() === itemId) { + if ($lastCompletedItemId.get() === itemId && $autoSwitch.get() === 'switch_on_finish') { $selectedItemId.set(itemId); $lastCompletedItemId.set(null); } }, - [$lastCompletedItemId, $progressData, $selectedItemId] + [$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId] ); // Set up socket listeners @@ -305,6 +320,9 @@ export const CanvasSessionContextProvider = memo( if (data.status === 'completed') { $lastCompletedItemId.set(data.item_id); } + if (data.status === 'in_progress') { + $lastStartedItemId.set(data.item_id); + } }; socket.on('invocation_progress', onProgress); @@ -314,7 +332,7 @@ export const CanvasSessionContextProvider = memo( socket.off('invocation_progress', onProgress); socket.off('queue_item_status_changed', onQueueItemStatusChanged); }; - }, [$autoSwitch, $lastCompletedItemId, $progressData, $selectedItemId, session.id, socket]); + }, [$autoSwitch, $lastCompletedItemId, $lastStartedItemId, $progressData, $selectedItemId, session.id, socket]); // Set up state subscriptions and effects useEffect(() => { @@ -333,29 +351,38 @@ export const CanvasSessionContextProvider = memo( }); // Handle cases that could result in a nonexistent queue item being selected. - const unsubEnsureSelectedItemIdExists = effect([$items, $selectedItemId], (items, selectedItemId) => { - // If there are no items, cannot have a selected item. - if (items.length === 0) { - $selectedItemId.set(null); - return; - } - // If there is no selected item but there are items, select the first one. - if (selectedItemId === null && items.length > 0) { - $selectedItemId.set(items[0]?.item_id ?? null); - return; - } - // 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. - if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) { - let prevIndex = _prevItems.findIndex(({ item_id }) => item_id === selectedItemId); - if (prevIndex >= items.length) { - prevIndex = items.length - 1; + const unsubEnsureSelectedItemIdExists = effect( + [$items, $selectedItemId, $lastStartedItemId], + (items, selectedItemId, lastStartedItemId) => { + // If there are no items, cannot have a selected item. + if (items.length === 0) { + $selectedItemId.set(null); + return; + } + // If there is no selected item but there are items, select the first one. + if (selectedItemId === null && items.length > 0) { + $selectedItemId.set(items[0]?.item_id ?? null); + return; + } + if ( + $autoSwitch.get() === 'switch_on_start' && + items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1 + ) { + $selectedItemId.set(lastStartedItemId); + } + // 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. + if (selectedItemId !== null && items.findIndex(({ item_id }) => item_id === selectedItemId) === -1) { + 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); + return; } - const nextItem = items[prevIndex]; - $selectedItemId.set(nextItem?.item_id ?? null); - return; } - }); + ); // Clean up the progress data when a queue item is discarded. const unsubCleanUpProgressData = $items.subscribe(async (items) => { @@ -438,7 +465,7 @@ export const CanvasSessionContextProvider = memo( if (lastLoadedItemId === null) { return; } - if ($autoSwitch.get()) { + if ($autoSwitch.get() === 'switch_on_finish') { $selectedItemId.set(lastLoadedItemId); } $lastLoadedItemId.set(null); @@ -461,7 +488,17 @@ export const CanvasSessionContextProvider = memo( $progressData.set({}); $selectedItemId.set(null); }; - }, [$autoSwitch, $items, $lastLoadedItemId, $progressData, $selectedItemId, selectQueueItems, session.id, store]); + }, [ + $autoSwitch, + $items, + $lastLoadedItemId, + $lastStartedItemId, + $progressData, + $selectedItemId, + selectQueueItems, + session.id, + store, + ]); const value = useMemo( () => ({ diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx index b67eefa9cf..bf6aa0ae51 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx @@ -1,7 +1,8 @@ import { MenuItemOption, MenuOptionGroup } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; +import { isAutoSwitchMode, useCanvasSessionContext } from 'features/controlLayers/components/SimpleSession/context'; import { memo, useCallback } from 'react'; +import { assert } from 'tsafe'; export const StagingAreaToolbarMenuAutoSwitch = memo(() => { const ctx = useCanvasSessionContext(); @@ -9,18 +10,22 @@ export const StagingAreaToolbarMenuAutoSwitch = memo(() => { const onChange = useCallback( (val: string | string[]) => { - ctx.$autoSwitch.set(val === 'on'); + assert(isAutoSwitchMode(val)); + ctx.$autoSwitch.set(val); }, [ctx.$autoSwitch] ); return ( - + Off - - On + + Switch on Start + + + Switch on Finish ); From 7208373576ce29cf39f0837c39da1d88cba88a75 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 11:38:13 +1000 Subject: [PATCH 135/210] fix(ui): reset last started item id when doing autoswitch --- .../features/controlLayers/components/SimpleSession/context.tsx | 1 + 1 file changed, 1 insertion(+) 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 45472b9ffd..efa2b6c907 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/context.tsx @@ -369,6 +369,7 @@ export const CanvasSessionContextProvider = memo( items.findIndex(({ item_id }) => item_id === lastStartedItemId) !== -1 ) { $selectedItemId.set(lastStartedItemId); + $lastStartedItemId.set(null); } // 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. From 36ec1015d64f9d6742b45ca399bbfb75b0196551 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 11:39:56 +1000 Subject: [PATCH 136/210] feat(ui): double-click staging area image to disable auto-switch --- .../SimpleSession/QueueItemPreviewMini.tsx | 19 ++++++++++++++++++- .../StagingAreaToolbarMenuAutoSwitch.tsx | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) 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 420abee061..5fb525638e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/QueueItemPreviewMini.tsx @@ -11,6 +11,7 @@ import { QueueItemProgressImage } from 'features/controlLayers/components/Simple import { QueueItemStatusLabel } from 'features/controlLayers/components/SimpleSession/QueueItemStatusLabel'; import { getQueueItemElementId } from 'features/controlLayers/components/SimpleSession/shared'; import { DndImage } from 'features/dnd/DndImage'; +import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; import type { S } from 'services/api/types'; @@ -46,12 +47,28 @@ export const QueueItemPreviewMini = memo(({ item, isSelected, number }: Props) = ctx.$selectedItemId.set(item.item_id); }, [ctx.$selectedItemId, item.item_id]); + const onDoubleClick = useCallback(() => { + const autoSwitch = ctx.$autoSwitch.get(); + if (autoSwitch !== 'off') { + ctx.$autoSwitch.set('off'); + toast({ + title: 'Auto-Switch Disabled', + }); + } + }, [ctx.$autoSwitch]); + const onLoad = useCallback(() => { ctx.onImageLoad(item.item_id); }, [ctx, item.item_id]); return ( - + {imageDTO && } {!imageLoaded && } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx index bf6aa0ae51..1e23208c74 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/StagingAreaToolbarMenuAutoSwitch.tsx @@ -17,7 +17,7 @@ export const StagingAreaToolbarMenuAutoSwitch = memo(() => { ); return ( - + Off From 5d8061bea96019b352002b9cbf20a2c654a8a085 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:00:51 +1000 Subject: [PATCH 137/210] fix(ui): staging area does not show placeholder on first render --- .../konva/CanvasStagingAreaModule.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStagingAreaModule.ts index 709fb2cbe4..1f1a1ced43 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 { ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context'; +import type { ProgressData, ProgressDataMap } from 'features/controlLayers/components/SimpleSession/context'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; @@ -135,8 +135,8 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { this.$isStaging.set(this.manager.stateApi.runSelector(selectIsStaging)); }; - connectToSession = ($selectedItemId: Atom, $progressData: ProgressDataMap) => - effect([$selectedItemId, $progressData], (selectedItemId, progressData) => { + connectToSession = ($selectedItemId: Atom, $progressData: ProgressDataMap) => { + const cb = (selectedItemId: number | null, progressData: Record) => { if (!selectedItemId) { this.$imageSrc.set(null); return; @@ -153,7 +153,14 @@ export class CanvasStagingAreaModule extends CanvasModuleBase { } else { this.$imageSrc.set(null); } - }); + }; + + // Run the effect & forcibly render once to initialize + cb($selectedItemId.get(), $progressData.get()); + this.render(); + + return effect([$selectedItemId, $progressData], cb); + }; private _getImageFromSrc = ( { type, data }: ImageNameSrc | DataURLSrc, From 34aa131115a942b289f9d617828f791439958c28 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:21:21 +1000 Subject: [PATCH 138/210] feat(ui): show last progress message & placeholder in generation progress panel --- .../components/SimpleSession/shared.ts | 9 +---- .../ImageViewer/GenerationProgressPanel.tsx | 5 ++- .../components/ImageViewer/ProgressImage.tsx | 14 ++++++- .../ImageViewer/ProgressIndicator.tsx | 38 +++++++++++++++++++ .../web/src/services/api/endpoints/queue.ts | 5 +++ .../web/src/services/events/stores.ts | 12 +++--- 6 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressIndicator.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts index 0026b2a3fa..be7e97d62a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/shared.ts @@ -1,19 +1,14 @@ import { isImageField } from 'features/nodes/types/common'; import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils'; -import { round } from 'lodash-es'; import type { S } from 'services/api/types'; +import { formatProgressMessage } from 'services/events/stores'; import { objectEntries } from 'tsafe'; export const getProgressMessage = (data?: S['InvocationProgressEvent'] | null) => { if (!data) { return 'Generating'; } - - let message = data.message; - if (data.percentage) { - message += ` (${round(data.percentage * 100)}%)`; - } - return message; + return formatProgressMessage(data); }; export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx index 23cce5ce90..970e88112f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/GenerationProgressPanel.tsx @@ -2,9 +2,12 @@ import { Flex } from '@invoke-ai/ui-library'; import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage'; import { memo } from 'react'; +import { ProgressIndicator } from './ProgressIndicator'; + export const GenerationProgressPanel = memo(() => ( - + + )); GenerationProgressPanel.displayName = 'GenerationProgressPanel'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx index 850ebc63e1..ffaf2d32bb 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage.tsx @@ -1,5 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Flex, Image } from '@invoke-ai/ui-library'; +import { Flex, Heading, Image } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; @@ -7,6 +7,7 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { selectSystemSlice } from 'features/system/store/systemSlice'; import { memo, useMemo } from 'react'; import { PiPulseBold } from 'react-icons/pi'; +import { useIsGenerationInProgress } from 'services/api/endpoints/queue'; import { $lastProgressImage } from 'services/events/stores'; const selectShouldAntialiasProgressImage = createSelector( @@ -15,6 +16,7 @@ const selectShouldAntialiasProgressImage = createSelector( ); export const ProgressImage = memo(() => { + const isGenerationInProgress = useIsGenerationInProgress(); const progressImage = useStore($lastProgressImage); const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage); @@ -25,7 +27,7 @@ export const ProgressImage = memo(() => { [shouldAntialiasProgressImage] ); - if (!progressImage) { + if (!isGenerationInProgress) { return ( @@ -33,6 +35,14 @@ export const ProgressImage = memo(() => { ); } + if (!progressImage) { + return ( + + Waiting for Image + + ); + } + return ( { + const isGenerationInProgress = useIsGenerationInProgress(); + const lastProgressEvent = useStore($lastProgressEvent); + if (!isGenerationInProgress) { + return null; + } + if (!lastProgressEvent) { + return null; + } + return ( + + + + ); +}); +ProgressIndicator.displayName = 'ProgressMessage'; diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index 75ff92e58d..0740a52cc8 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -396,3 +396,8 @@ export const selectCanvasQueueCounts = queueApi.endpoints.getQueueCountsByDestin export const enqueueMutationFixedCacheKeyOptions = { fixedCacheKey: 'enqueueBatch', } as const; + +export const useIsGenerationInProgress = () => { + const { data } = useGetQueueStatusQuery(); + return data && data.queue.in_progress > 0; +}; diff --git a/invokeai/frontend/web/src/services/events/stores.ts b/invokeai/frontend/web/src/services/events/stores.ts index c86e147ea4..a57f2c9260 100644 --- a/invokeai/frontend/web/src/services/events/stores.ts +++ b/invokeai/frontend/web/src/services/events/stores.ts @@ -21,10 +21,12 @@ export const $lastProgressMessage = computed($lastProgressEvent, (val) => { if (!val) { return null; } - - let message = val.message; - if (val.percentage) { - message += ` (${round(val.percentage * 100)}%)`; + return formatProgressMessage(val); +}); +export const formatProgressMessage = (data: S['InvocationProgressEvent']): string => { + let message = data.message; + if (data.percentage) { + message += ` (${round(data.percentage * 100)}%)`; } return message; -}); +}; From 214005d795382acbb152b0c6df3cbde075bdd943 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:57:04 +1000 Subject: [PATCH 139/210] feat(ui): generation progress tab improvements --- .../system/components/ProgressBar.tsx | 4 ++- .../ui/layouts/TabWithoutCloseButton.tsx | 4 ++- ...outCloseButtonAndWithProgressIndicator.tsx | 31 +++++++++++++++++++ .../ui/layouts/canvas-tab-auto-layout.tsx | 14 ++++++++- .../ui/layouts/generate-tab-auto-layout.tsx | 13 +++++++- .../web/src/features/ui/layouts/shared.ts | 3 ++ .../ui/layouts/upscaling-tab-auto-layout.tsx | 13 +++++++- .../ui/layouts/workflows-tab-auto-layout.tsx | 14 ++++++++- .../ui/styles/dockview-theme-invoke.css | 5 +-- 9 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx diff --git a/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx b/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx index 218ca382b8..5cb2a96660 100644 --- a/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx +++ b/invokeai/frontend/web/src/features/system/components/ProgressBar.tsx @@ -1,3 +1,4 @@ +import type { ProgressProps } from '@invoke-ai/ui-library'; import { Progress } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { memo, useMemo } from 'react'; @@ -5,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; import { $isConnected, $lastProgressEvent } from 'services/events/stores'; -const ProgressBar = () => { +const ProgressBar = (props: ProgressProps) => { const { t } = useTranslation(); const { data: queueStatus } = useGetQueueStatusQuery(); const isConnected = useStore($isConnected); @@ -45,6 +46,7 @@ const ProgressBar = () => { h={2} w="full" colorScheme="invokeBlue" + {...props} /> ); }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx index 84dbb1e7ad..71638d93e3 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx @@ -15,7 +15,9 @@ export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { return ( - {props.api.title ?? props.api.id} + + {props.api.title ?? props.api.id} + ); }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx new file mode 100644 index 0000000000..3d1b960abf --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx @@ -0,0 +1,31 @@ +import { Flex, Text } from '@invoke-ai/ui-library'; +import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; +import type { IDockviewPanelHeaderProps } from 'dockview'; +import ProgressBar from 'features/system/components/ProgressBar'; +import { useCallback, useRef } from 'react'; +import { useIsGenerationInProgress } from 'services/api/endpoints/queue'; + +export const TabWithoutCloseButtonAndWithProgressIndicator = (props: IDockviewPanelHeaderProps) => { + const isGenerationInProgress = useIsGenerationInProgress(); + + const ref = useRef(null); + const setActive = useCallback(() => { + if (!props.api.isActive) { + props.api.setActive(); + } + }, [props.api]); + + useCallbackOnDragEnter(setActive, ref, 300); + + return ( + + + {props.api.title ?? props.api.id} + + {isGenerationInProgress && ( + + )} + + ); +}; +TabWithoutCloseButtonAndWithProgressIndicator.displayName = 'TabWithoutCloseButtonAndWithProgressIndicator'; 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 b4e5971972..727b5342b2 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 @@ -18,6 +18,7 @@ import { CanvasTabLeftPanel } from './CanvasTabLeftPanel'; import { CanvasWorkspacePanel } from './CanvasWorkspacePanel'; import { BOARDS_PANEL_ID, + DEFAULT_TAB_ID, GALLERY_PANEL_ID, LAUNCHPAD_PANEL_ID, LAYERS_PANEL_ID, @@ -28,11 +29,18 @@ import { RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TAB_WITH_PROGRESS_INDICATOR_ID, VIEWER_PANEL_ID, WORKSPACE_PANEL_ID, } from './shared'; +import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; +const tabComponents = { + [DEFAULT_TAB_ID]: TabWithoutCloseButton, + [TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator, +}; + const centerPanelComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: CanvasLaunchpadPanel, [WORKSPACE_PANEL_ID]: CanvasWorkspacePanel, @@ -45,11 +53,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, title: 'Launchpad', + tabComponent: DEFAULT_TAB_ID, }); api.addPanel({ id: WORKSPACE_PANEL_ID, component: WORKSPACE_PANEL_ID, title: 'Canvas', + tabComponent: DEFAULT_TAB_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -59,6 +69,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', + tabComponent: DEFAULT_TAB_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -68,6 +79,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: PROGRESS_PANEL_ID, component: PROGRESS_PANEL_ID, title: 'Generation Progress', + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -107,10 +119,10 @@ const CenterPanel = memo(() => { locked={true} disableFloatingGroups={true} dndEdges={false} - defaultTabComponent={TabWithoutCloseButton} components={centerPanelComponents} onReady={onReady} theme={dockviewTheme} + tabComponents={tabComponents} /> 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 ec87e58d46..d2254db0d3 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 @@ -16,6 +16,7 @@ import { memo, useCallback, useRef, useState } from 'react'; import { GenerateTabLeftPanel } from './GenerateTabLeftPanel'; import { BOARDS_PANEL_ID, + DEFAULT_TAB_ID, GALLERY_PANEL_ID, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, @@ -25,10 +26,17 @@ import { RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TAB_WITH_PROGRESS_INDICATOR_ID, VIEWER_PANEL_ID, } from './shared'; +import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; +const tabComponents = { + [DEFAULT_TAB_ID]: TabWithoutCloseButton, + [TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator, +}; + const centerPanelComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: GenerateLaunchpadPanel, [VIEWER_PANEL_ID]: ImageViewerPanel, @@ -40,11 +48,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, title: 'Launchpad', + tabComponent: DEFAULT_TAB_ID, }); api.addPanel({ id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', + tabComponent: DEFAULT_TAB_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -54,6 +64,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: PROGRESS_PANEL_ID, component: PROGRESS_PANEL_ID, title: 'Generation Progress', + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -93,7 +104,7 @@ const CenterPanel = memo(() => { locked={true} disableFloatingGroups={true} dndEdges={false} - defaultTabComponent={TabWithoutCloseButton} + tabComponents={tabComponents} components={centerPanelComponents} onReady={onReady} theme={dockviewTheme} diff --git a/invokeai/frontend/web/src/features/ui/layouts/shared.ts b/invokeai/frontend/web/src/features/ui/layouts/shared.ts index 6c160e295e..ca120634c8 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/shared.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/shared.ts @@ -13,5 +13,8 @@ export const LAYERS_PANEL_ID = 'layers'; export const SETTINGS_PANEL_ID = 'settings'; +export const DEFAULT_TAB_ID = 'default-tab'; +export const TAB_WITH_PROGRESS_INDICATOR_ID = 'tab-with-progress-indicator'; + export const LEFT_PANEL_MIN_SIZE_PX = 420; export const RIGHT_PANEL_MIN_SIZE_PX = 420; 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 a0a57b96f2..d805e0feb8 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 @@ -15,6 +15,7 @@ import { memo, useCallback, useRef, useState } from 'react'; import { BOARDS_PANEL_ID, + DEFAULT_TAB_ID, GALLERY_PANEL_ID, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, @@ -24,11 +25,18 @@ import { RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TAB_WITH_PROGRESS_INDICATOR_ID, VIEWER_PANEL_ID, } from './shared'; +import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator'; import { UpscalingTabLeftPanel } from './UpscalingTabLeftPanel'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; +const tabComponents = { + [DEFAULT_TAB_ID]: TabWithoutCloseButton, + [TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator, +}; + const centerComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: UpscalingLaunchpadPanel, [VIEWER_PANEL_ID]: ImageViewerPanel, @@ -40,11 +48,13 @@ const initializeCenterLayout = (api: DockviewApi) => { id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, title: 'Launchpad', + tabComponent: DEFAULT_TAB_ID, }); api.addPanel({ id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', + tabComponent: DEFAULT_TAB_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -54,6 +64,7 @@ const initializeCenterLayout = (api: DockviewApi) => { id: PROGRESS_PANEL_ID, component: PROGRESS_PANEL_ID, title: 'Generation Progress', + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -92,7 +103,7 @@ const CenterPanel = memo(() => { locked={true} disableFloatingGroups={true} dndEdges={false} - defaultTabComponent={TabWithoutCloseButton} + tabComponents={tabComponents} components={centerComponents} onReady={onReady} theme={dockviewTheme} 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 966e43cf88..dfb539dcd7 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 @@ -17,6 +17,7 @@ import { memo, useCallback, useRef, useState } from 'react'; import { BOARDS_PANEL_ID, + DEFAULT_TAB_ID, GALLERY_PANEL_ID, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, @@ -26,11 +27,18 @@ import { RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX, SETTINGS_PANEL_ID, + TAB_WITH_PROGRESS_INDICATOR_ID, VIEWER_PANEL_ID, WORKSPACE_PANEL_ID, } from './shared'; +import { TabWithoutCloseButtonAndWithProgressIndicator } from './TabWithoutCloseButtonAndWithProgressIndicator'; import { useResizeMainPanelOnFirstVisit } from './use-on-first-visible'; +const tabComponents = { + [DEFAULT_TAB_ID]: TabWithoutCloseButton, + [TAB_WITH_PROGRESS_INDICATOR_ID]: TabWithoutCloseButtonAndWithProgressIndicator, +}; + const centerPanelComponents: IDockviewReactProps['components'] = { [LAUNCHPAD_PANEL_ID]: WorkflowsLaunchpadPanel, [WORKSPACE_PANEL_ID]: NodeEditor, @@ -43,11 +51,13 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, title: 'Launchpad', + tabComponent: DEFAULT_TAB_ID, }); api.addPanel({ id: WORKSPACE_PANEL_ID, component: WORKSPACE_PANEL_ID, title: 'Workflow Editor', + tabComponent: DEFAULT_TAB_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -57,6 +67,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', + tabComponent: DEFAULT_TAB_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -66,6 +77,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: PROGRESS_PANEL_ID, component: PROGRESS_PANEL_ID, title: 'Generation Progress', + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, @@ -105,7 +117,7 @@ const CenterPanel = memo(() => { locked={true} disableFloatingGroups={true} dndEdges={false} - defaultTabComponent={TabWithoutCloseButton} + tabComponents={tabComponents} components={centerPanelComponents} onReady={onReady} theme={dockviewTheme} diff --git a/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css b/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css index 49aab65e53..32acdb7378 100644 --- a/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css +++ b/invokeai/frontend/web/src/features/ui/styles/dockview-theme-invoke.css @@ -57,8 +57,9 @@ .dv-tab { /* margin-right: 2px; */ - padding-inline-start: var(--invoke-sizes-4); - padding-inline-end: var(--invoke-sizes-4); + /* padding-inline-start: var(--invoke-sizes-4); + padding-inline-end: var(--invoke-sizes-4); */ + padding: 0px; } .dv-inactive-group .dv-tabs-container.dv-horizontal .dv-tab:not(:first-child)::before { From f0ba693922474cbeb27caca20b87af3d6b7546af Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:21:45 +1000 Subject: [PATCH 140/210] feat(ui): switch to viewer/canvas on invoke --- .../components/ImageGrid/GalleryImage.tsx | 3 +- .../GalleryImageOpenInViewerIconButton.tsx | 7 ++-- .../web/src/features/queue/hooks/useInvoke.ts | 17 +++++++-- .../ui/layouts/auto-layout-context.tsx | 36 ++++++++++--------- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index ca73a3c16f..e54f12d82c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -17,6 +17,7 @@ import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/Ga import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; +import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -205,7 +206,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { const onDoubleClick = useCallback>(() => { store.dispatch(imageToCompareChanged(null)); - autoLayoutContext.focusImageViewer(); + autoLayoutContext.focusPanel(VIEWER_PANEL_ID); }, [autoLayoutContext, store]); const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx index ac694369b9..bd742430ea 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -2,6 +2,7 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; +import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; @@ -13,14 +14,14 @@ type Props = { export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => { const dispatch = useAppDispatch(); - const { focusImageViewer } = useAutoLayoutContext(); + const { focusPanel } = useAutoLayoutContext(); const { t } = useTranslation(); const onClick = useCallback(() => { dispatch(imageToCompareChanged(null)); dispatch(imageSelected(imageDTO)); - focusImageViewer(); - }, [dispatch, focusImageViewer, imageDTO]); + focusPanel(VIEWER_PANEL_ID); + }, [dispatch, focusPanel, imageDTO]); return ( { const dispatch = useAppDispatch(); + const ctx = useAutoLayoutContextSafe(); const tabName = useAppSelector(selectActiveTab); const isReady = useStore($isReadyToEnqueue); const isLocked = useIsWorkflowEditorLocked(); @@ -56,11 +59,21 @@ export const useInvoke = () => { const enqueueBack = useCallback(() => { enqueue(false, false); - }, [enqueue]); + if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') { + ctx?.focusPanel(VIEWER_PANEL_ID); + } else if (tabName === 'canvas') { + ctx?.focusPanel(WORKSPACE_PANEL_ID); + } + }, [ctx, enqueue, tabName]); const enqueueFront = useCallback(() => { enqueue(true, false); - }, [enqueue]); + if (tabName === 'generate' || tabName === 'workflows' || tabName === 'upscaling') { + ctx?.focusPanel(VIEWER_PANEL_ID); + } else if (tabName === 'canvas') { + ctx?.focusPanel(WORKSPACE_PANEL_ID); + } + }, [ctx, enqueue, tabName]); return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady || isLocked, enqueue }; }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx b/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx index 8a1bec26ee..4f4cd9ea9e 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/auto-layout-context.tsx @@ -5,20 +5,14 @@ import { atom } from 'nanostores'; import type { PropsWithChildren } from 'react'; import { createContext, memo, useCallback, useContext, useMemo, useState } from 'react'; -import { - LEFT_PANEL_ID, - LEFT_PANEL_MIN_SIZE_PX, - RIGHT_PANEL_ID, - RIGHT_PANEL_MIN_SIZE_PX, - VIEWER_PANEL_ID, -} from './shared'; +import { LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX } from './shared'; type AutoLayoutContextValue = { toggleLeftPanel: () => void; toggleRightPanel: () => void; toggleBothPanels: () => void; resetPanels: () => void; - focusImageViewer: () => void; + focusPanel: (id: string) => void; _$rootPanelApi: WritableAtom; _$leftPanelApi: WritableAtom; _$centerPanelApi: WritableAtom; @@ -116,13 +110,16 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable expandPanel(api, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX); }, [$rootApi]); - const focusImageViewer = useCallback(() => { - const api = $centerApi.get(); - if (!api) { - return; - } - activatePanel(api, VIEWER_PANEL_ID); - }, [$centerApi]); + const focusPanel = useCallback( + (id: string) => { + const api = $centerApi.get(); + if (!api) { + return; + } + activatePanel(api, id); + }, + [$centerApi] + ); const value = useMemo( () => ({ @@ -130,7 +127,7 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable toggleRightPanel, toggleBothPanels, resetPanels, - focusImageViewer, + focusPanel, _$rootPanelApi: $rootApi, _$leftPanelApi: $leftApi, _$centerPanelApi: $centerApi, @@ -141,7 +138,7 @@ export const AutoLayoutProvider = (props: PropsWithChildren<{ $rootApi: Writable $leftApi, $rightApi, $rootApi, - focusImageViewer, + focusPanel, resetPanels, toggleBothPanels, toggleLeftPanel, @@ -159,6 +156,11 @@ export const useAutoLayoutContext = () => { return value; }; +export const useAutoLayoutContextSafe = () => { + const value = useContext(AutoLayoutContext); + return value; +}; + export const PanelHotkeysLogical = memo(() => { const { toggleBothPanels, resetPanels, toggleLeftPanel, toggleRightPanel } = useAutoLayoutContext(); useRegisteredHotkeys({ From d23cdfd0addec4f345a85f41400440ae630088c6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:51:37 +1000 Subject: [PATCH 141/210] feat(ui): viewer integrates progress (wip) --- .../ImageViewer/CurrentImagePreview.tsx | 67 ++++++++++++++----- .../components/ImageViewer/Progress.tsx | 22 ++++++ .../components/ImageViewer/ProgressImage2.tsx | 44 ++++++++++++ .../ImageViewer/ProgressIndicator2.tsx | 31 +++++++++ .../ui/layouts/TabWithoutCloseButton.tsx | 6 +- ...outCloseButtonAndWithProgressIndicator.tsx | 6 +- .../ui/layouts/canvas-tab-auto-layout.tsx | 2 +- .../ui/layouts/generate-tab-auto-layout.tsx | 2 +- .../ui/layouts/upscaling-tab-auto-layout.tsx | 2 +- .../ui/layouts/workflows-tab-auto-layout.tsx | 2 +- 10 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/Progress.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressIndicator2.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 687cddd7e7..7d0ef2e1d4 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -1,20 +1,28 @@ import { Box, Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; +import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; -import { memo, useCallback, useRef, useState } from 'react'; -import type { ImageDTO } from 'services/api/types'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import type { ImageDTO, S } from 'services/api/types'; +import { $socket } from 'services/events/stores'; import { ImageMetadataMini } from './ImageMetadataMini'; import { NoContentForViewer } from './NoContentForViewer'; +import { ProgressImage } from './ProgressImage2'; +import { ProgressIndicator } from './ProgressIndicator2'; export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); + const socket = useStore($socket); + const [progressEvent, setProgressEvent] = useState(null); + const [progressImage, setProgressImage] = useState(null); // Show and hide the next/prev buttons on mouse move const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); @@ -29,6 +37,34 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) }, 500); }, []); + useEffect(() => { + if (!socket) { + return; + } + + const onInvocationProgress = (data: S['InvocationProgressEvent']) => { + setProgressEvent(data); + if (data.image) { + setProgressImage(data.image); + } + }; + + socket.on('invocation_progress', onInvocationProgress); + + return () => { + socket.off('invocation_progress', onInvocationProgress); + }; + }, [socket]); + + const onLoadImage = useCallback(() => { + if (!progressEvent || !imageDTO) { + return; + } + if (progressEvent.session_id === imageDTO.session_id) { + setProgressImage(null); + } + }, [imageDTO, progressEvent]); + return ( - + {imageDTO ? ( + + + + ) : ( + + )} + {progressEvent && progressImage && ( + + + + + )} {imageDTO && } @@ -73,19 +121,6 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) }); CurrentImagePreview.displayName = 'CurrentImagePreview'; -const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { - if (!imageDTO) { - return ; - } - - return ( - - - - ); -}); -ImageContent.displayName = 'ImageContent'; - const initial: AnimationProps['initial'] = { opacity: 0, }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/Progress.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/Progress.tsx new file mode 100644 index 0000000000..bc6aedff31 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/Progress.tsx @@ -0,0 +1,22 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { ProgressImage } from 'features/gallery/components/ImageViewer/ProgressImage2'; +import { ProgressIndicator } from 'features/gallery/components/ImageViewer/ProgressIndicator2'; +import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; +import { memo } from 'react'; +import type { S } from 'services/api/types'; + +export const Progress = memo( + ({ + progressEvent, + progressImage, + }: { + progressEvent: S['InvocationProgressEvent']; + progressImage: ProgressImageType; + }) => ( + + + + + ) +); +Progress.displayName = 'Progress'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx new file mode 100644 index 0000000000..3e5a452a8c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressImage2.tsx @@ -0,0 +1,44 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex, Image } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; +import { selectSystemSlice } from 'features/system/store/systemSlice'; +import { memo, useMemo } from 'react'; + +const selectShouldAntialiasProgressImage = createSelector( + selectSystemSlice, + (system) => system.shouldAntialiasProgressImage +); + +export const ProgressImage = memo(({ progressImage }: { progressImage: ProgressImageType }) => { + const shouldAntialiasProgressImage = useAppSelector(selectShouldAntialiasProgressImage); + + const sx = useMemo( + () => ({ + imageRendering: shouldAntialiasProgressImage ? 'auto' : 'pixelated', + }), + [shouldAntialiasProgressImage] + ); + + return ( + + + + ); +}); + +ProgressImage.displayName = 'ProgressImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressIndicator2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressIndicator2.tsx new file mode 100644 index 0000000000..b635c37d80 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ProgressIndicator2.tsx @@ -0,0 +1,31 @@ +import type { CircularProgressProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import { CircularProgress, Tooltip } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import type { S } from 'services/api/types'; +import { formatProgressMessage } from 'services/events/stores'; + +const circleStyles: SystemStyleObject = { + circle: { + transitionProperty: 'none', + transitionDuration: '0s', + }, +}; + +export const ProgressIndicator = memo( + ({ progressEvent, ...rest }: { progressEvent: S['InvocationProgressEvent'] } & CircularProgressProps) => { + return ( + + + + ); + } +); +ProgressIndicator.displayName = 'ProgressMessage'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx index 71638d93e3..7bb0f240ad 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButton.tsx @@ -1,9 +1,9 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; import type { IDockviewPanelHeaderProps } from 'dockview'; -import { useCallback, useRef } from 'react'; +import { memo, useCallback, useRef } from 'react'; -export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { +export const TabWithoutCloseButton = memo((props: IDockviewPanelHeaderProps) => { const ref = useRef(null); const setActive = useCallback(() => { if (!props.api.isActive) { @@ -20,5 +20,5 @@ export const TabWithoutCloseButton = (props: IDockviewPanelHeaderProps) => { ); -}; +}); TabWithoutCloseButton.displayName = 'TabWithoutCloseButton'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx index 3d1b960abf..7ac349116f 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/TabWithoutCloseButtonAndWithProgressIndicator.tsx @@ -2,10 +2,10 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter'; import type { IDockviewPanelHeaderProps } from 'dockview'; import ProgressBar from 'features/system/components/ProgressBar'; -import { useCallback, useRef } from 'react'; +import { memo, useCallback, useRef } from 'react'; import { useIsGenerationInProgress } from 'services/api/endpoints/queue'; -export const TabWithoutCloseButtonAndWithProgressIndicator = (props: IDockviewPanelHeaderProps) => { +export const TabWithoutCloseButtonAndWithProgressIndicator = memo((props: IDockviewPanelHeaderProps) => { const isGenerationInProgress = useIsGenerationInProgress(); const ref = useRef(null); @@ -27,5 +27,5 @@ export const TabWithoutCloseButtonAndWithProgressIndicator = (props: IDockviewPa )} ); -}; +}); TabWithoutCloseButtonAndWithProgressIndicator.displayName = 'TabWithoutCloseButtonAndWithProgressIndicator'; 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 727b5342b2..c82a7832f5 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 @@ -69,7 +69,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', - tabComponent: DEFAULT_TAB_ID, + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, 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 d2254db0d3..b96059aa32 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 @@ -54,7 +54,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', - tabComponent: DEFAULT_TAB_ID, + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, 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 d805e0feb8..22f2b26da2 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 @@ -54,7 +54,7 @@ const initializeCenterLayout = (api: DockviewApi) => { id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', - tabComponent: DEFAULT_TAB_ID, + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, 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 dfb539dcd7..07974fa3fb 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 @@ -67,7 +67,7 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: 'Image Viewer', - tabComponent: DEFAULT_TAB_ID, + tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, position: { direction: 'within', referencePanel: LAUNCHPAD_PANEL_ID, From 4028cadfaff2fcdeb54752bae54aff0783b08939 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:53:12 +1000 Subject: [PATCH 142/210] feat(api): return more data when doing image/board mutations When we delete images, boards, or do any other board mutation, we need to invalidate numerous query caches and related internal frontend state. This gets complicated very quickly. We can drastically reduce the complexity by having the backend return some more information when we make these mutations. For example, when deleting a list of images by name, we can return a list of deleted image name and affected boards. The frontend can use this information to determine which queries to invalidate with far less tedium. This will also enable the more efficient storage of images (e.g. in the gallery selection). Previously, we had to store the entire image DTO object, else we wouldn't be able to figure out which queries to invalidate. But now that the backend tells us exactly what images/boards have changed, we can just store image names in frontend state. This amounts to a substantial improvement in DX and reduction in frontend complexity. --- invokeai/app/api/routers/board_images.py | 75 ++++++++----- invokeai/app/api/routers/images.py | 100 ++++++++++++------ invokeai/app/services/images/images_common.py | 26 ++++- 3 files changed, 145 insertions(+), 56 deletions(-) diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index eb193f6585..cb5e0ab51a 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -1,21 +1,12 @@ from fastapi import Body, HTTPException from fastapi.routing import APIRouter -from pydantic import BaseModel, Field from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"]) -class AddImagesToBoardResult(BaseModel): - board_id: str = Field(description="The id of the board the images were added to") - added_image_names: list[str] = Field(description="The image names that were added to the board") - - -class RemoveImagesFromBoardResult(BaseModel): - removed_image_names: list[str] = Field(description="The image names that were removed from their board") - - @board_images_router.post( "/", operation_id="add_image_to_board", @@ -23,17 +14,26 @@ class RemoveImagesFromBoardResult(BaseModel): 201: {"description": "The image was added to a board successfully"}, }, status_code=201, + response_model=AddImagesToBoardResult, ) async def add_image_to_board( board_id: str = Body(description="The id of the board to add to"), image_name: str = Body(description="The name of the image to add"), -): +) -> AddImagesToBoardResult: """Creates a board_image""" try: - result = ApiDependencies.invoker.services.board_images.add_image_to_board( - board_id=board_id, image_name=image_name + added_images: set[str] = set() + affected_boards: set[str] = set() + old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" + ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name) + added_images.add(image_name) + affected_boards.add(board_id) + affected_boards.add(old_board_id) + + return AddImagesToBoardResult( + added_images=list(added_images), + affected_boards=list(affected_boards), ) - return result except Exception: raise HTTPException(status_code=500, detail="Failed to add image to board") @@ -45,14 +45,25 @@ async def add_image_to_board( 201: {"description": "The image was removed from the board successfully"}, }, status_code=201, + response_model=RemoveImagesFromBoardResult, ) async def remove_image_from_board( image_name: str = Body(description="The name of the image to remove", embed=True), -): +) -> RemoveImagesFromBoardResult: """Removes an image from its board, if it had one""" try: - result = ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) - return result + removed_images: set[str] = set() + affected_boards: set[str] = set() + old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" + ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) + removed_images.add(image_name) + affected_boards.add("none") + affected_boards.add(old_board_id) + return RemoveImagesFromBoardResult( + removed_images=list(removed_images), + affected_boards=list(affected_boards), + ) + except Exception: raise HTTPException(status_code=500, detail="Failed to remove image from board") @@ -72,16 +83,25 @@ async def add_images_to_board( ) -> AddImagesToBoardResult: """Adds a list of images to a board""" try: - added_image_names: list[str] = [] + added_images: set[str] = set() + affected_boards: set[str] = set() for image_name in image_names: try: + old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" ApiDependencies.invoker.services.board_images.add_image_to_board( - board_id=board_id, image_name=image_name + board_id=board_id, + image_name=image_name, ) - added_image_names.append(image_name) + added_images.add(image_name) + affected_boards.add(board_id) + affected_boards.add(old_board_id) + except Exception: pass - return AddImagesToBoardResult(board_id=board_id, added_image_names=added_image_names) + return AddImagesToBoardResult( + added_images=list(added_images), + affected_boards=list(affected_boards), + ) except Exception: raise HTTPException(status_code=500, detail="Failed to add images to board") @@ -100,13 +120,20 @@ async def remove_images_from_board( ) -> RemoveImagesFromBoardResult: """Removes a list of images from their board, if they had one""" try: - removed_image_names: list[str] = [] + removed_images: set[str] = set() + affected_boards: set[str] = set() for image_name in image_names: try: + old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) - removed_image_names.append(image_name) + removed_images.add(image_name) + affected_boards.add("none") + affected_boards.add(old_board_id) except Exception: pass - return RemoveImagesFromBoardResult(removed_image_names=removed_image_names) + return RemoveImagesFromBoardResult( + removed_images=list(removed_images), + affected_boards=list(affected_boards), + ) except Exception: raise HTTPException(status_code=500, detail="Failed to remove images from board") diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index f65cafc731..a2ac6b45c8 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -17,7 +17,13 @@ from invokeai.app.services.image_records.image_records_common import ( ImageRecordChanges, ResourceOrigin, ) -from invokeai.app.services.images.images_common import ImageDTO, ImageUrlsDTO +from invokeai.app.services.images.images_common import ( + DeleteImagesResult, + ImageDTO, + ImageUrlsDTO, + StarredImagesResult, + UnstarredImagesResult, +) from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.util.controlnet_utils import heuristic_resize_fast @@ -153,18 +159,30 @@ async def create_image_upload_entry( raise HTTPException(status_code=501, detail="Not implemented") -@images_router.delete("/i/{image_name}", operation_id="delete_image") +@images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult) async def delete_image( image_name: str = Path(description="The name of the image to delete"), -) -> None: +) -> DeleteImagesResult: """Deletes an image""" + deleted_images: set[str] = set() + affected_boards: set[str] = set() + try: + image_dto = ApiDependencies.invoker.services.images.get_dto(image_name) + board_id = image_dto.board_id or "none" ApiDependencies.invoker.services.images.delete(image_name) + deleted_images.add(image_name) + affected_boards.add(board_id) except Exception: # TODO: Does this need any exception handling at all? pass + return DeleteImagesResult( + deleted_images=list(deleted_images), + affected_boards=list(affected_boards), + ) + @images_router.delete("/intermediates", operation_id="clear_intermediates") async def clear_intermediates() -> int: @@ -376,31 +394,32 @@ async def list_image_dtos( return image_dtos -class DeleteImagesFromListResult(BaseModel): - deleted_images: list[str] - - -@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesFromListResult) +@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult) async def delete_images_from_list( image_names: list[str] = Body(description="The list of names of images to delete", embed=True), -) -> DeleteImagesFromListResult: +) -> DeleteImagesResult: try: - deleted_images: list[str] = [] + deleted_images: set[str] = set() + affected_boards: set[str] = set() for image_name in image_names: try: + image_dto = ApiDependencies.invoker.services.images.get_dto(image_name) + board_id = image_dto.board_id or "none" ApiDependencies.invoker.services.images.delete(image_name) - deleted_images.append(image_name) + deleted_images.add(image_name) + affected_boards.add(board_id) except Exception: pass - return DeleteImagesFromListResult(deleted_images=deleted_images) + return DeleteImagesResult( + deleted_images=list(deleted_images), + affected_boards=list(affected_boards), + ) except Exception: raise HTTPException(status_code=500, detail="Failed to delete images") -@images_router.delete( - "/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesFromListResult -) -async def delete_uncategorized_images() -> DeleteImagesFromListResult: +@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult) +async def delete_uncategorized_images() -> DeleteImagesResult: """Deletes all images that are uncategorized""" image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( @@ -408,14 +427,19 @@ async def delete_uncategorized_images() -> DeleteImagesFromListResult: ) try: - deleted_images: list[str] = [] + deleted_images: set[str] = set() + affected_boards: set[str] = set() for image_name in image_names: try: ApiDependencies.invoker.services.images.delete(image_name) - deleted_images.append(image_name) + deleted_images.add(image_name) + affected_boards.add("none") except Exception: pass - return DeleteImagesFromListResult(deleted_images=deleted_images) + return DeleteImagesResult( + deleted_images=list(deleted_images), + affected_boards=list(affected_boards), + ) except Exception: raise HTTPException(status_code=500, detail="Failed to delete images") @@ -424,36 +448,50 @@ class ImagesUpdatedFromListResult(BaseModel): updated_image_names: list[str] = Field(description="The image names that were updated") -@images_router.post("/star", operation_id="star_images_in_list", response_model=ImagesUpdatedFromListResult) +@images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult) async def star_images_in_list( image_names: list[str] = Body(description="The list of names of images to star", embed=True), -) -> ImagesUpdatedFromListResult: +) -> StarredImagesResult: try: - updated_image_names: list[str] = [] + starred_images: set[str] = set() + affected_boards: set[str] = set() for image_name in image_names: try: - ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=True)) - updated_image_names.append(image_name) + updated_image_dto = ApiDependencies.invoker.services.images.update( + image_name, changes=ImageRecordChanges(starred=True) + ) + starred_images.add(image_name) + affected_boards.add(updated_image_dto.board_id or "none") except Exception: pass - return ImagesUpdatedFromListResult(updated_image_names=updated_image_names) + return StarredImagesResult( + starred_images=list(starred_images), + affected_boards=list(affected_boards), + ) except Exception: raise HTTPException(status_code=500, detail="Failed to star images") -@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=ImagesUpdatedFromListResult) +@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult) async def unstar_images_in_list( image_names: list[str] = Body(description="The list of names of images to unstar", embed=True), -) -> ImagesUpdatedFromListResult: +) -> UnstarredImagesResult: try: - updated_image_names: list[str] = [] + unstarred_images: set[str] = set() + affected_boards: set[str] = set() for image_name in image_names: try: - ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=False)) - updated_image_names.append(image_name) + updated_image_dto = ApiDependencies.invoker.services.images.update( + image_name, changes=ImageRecordChanges(starred=False) + ) + unstarred_images.add(image_name) + affected_boards.add(updated_image_dto.board_id or "none") except Exception: pass - return ImagesUpdatedFromListResult(updated_image_names=updated_image_names) + return UnstarredImagesResult( + unstarred_images=list(unstarred_images), + affected_boards=list(affected_boards), + ) except Exception: raise HTTPException(status_code=500, detail="Failed to unstar images") diff --git a/invokeai/app/services/images/images_common.py b/invokeai/app/services/images/images_common.py index 0464244b94..311f6e556d 100644 --- a/invokeai/app/services/images/images_common.py +++ b/invokeai/app/services/images/images_common.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import Field +from pydantic import BaseModel, Field from invokeai.app.services.image_records.image_records_common import ImageRecord from invokeai.app.util.model_exclude_null import BaseModelExcludeNull @@ -39,3 +39,27 @@ def image_record_to_dto( thumbnail_url=thumbnail_url, board_id=board_id, ) + + +class ResultWithAffectedBoards(BaseModel): + affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation") + + +class DeleteImagesResult(ResultWithAffectedBoards): + deleted_images: list[str] = Field(description="The names of the images that were deleted") + + +class StarredImagesResult(ResultWithAffectedBoards): + starred_images: list[str] = Field(description="The names of the images that were starred") + + +class UnstarredImagesResult(ResultWithAffectedBoards): + unstarred_images: list[str] = Field(description="The names of the images that were unstarred") + + +class AddImagesToBoardResult(ResultWithAffectedBoards): + added_images: list[str] = Field(description="The image names that were added to the board") + + +class RemoveImagesFromBoardResult(ResultWithAffectedBoards): + removed_images: list[str] = Field(description="The image names that were removed from their board") From 70382294f560680c7959b0c761df8be7923d17e9 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:53:28 +1000 Subject: [PATCH 143/210] chore(ui): typegen --- .../frontend/web/src/services/api/schema.ts | 81 +++++++++++++------ 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 20a5903edb..827bc71e9e 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1781,15 +1781,15 @@ export type components = { /** AddImagesToBoardResult */ AddImagesToBoardResult: { /** - * Board Id - * @description The id of the board the images were added to + * Affected Boards + * @description The ids of boards affected by the delete operation */ - board_id: string; + affected_boards: string[]; /** - * Added Image Names + * Added Images * @description The image names that were added to the board */ - added_image_names: string[]; + added_images: string[]; }; /** * Add Integers @@ -5945,9 +5945,17 @@ export type components = { */ deleted: number; }; - /** DeleteImagesFromListResult */ - DeleteImagesFromListResult: { - /** Deleted Images */ + /** DeleteImagesResult */ + DeleteImagesResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Deleted Images + * @description The names of the images that were deleted + */ deleted_images: string[]; }; /** @@ -11024,14 +11032,6 @@ export type components = { */ bulk_download_item_name?: string | null; }; - /** ImagesUpdatedFromListResult */ - ImagesUpdatedFromListResult: { - /** - * Updated Image Names - * @description The image names that were updated - */ - updated_image_names: string[]; - }; /** * Solid Color Infill * @description Infills transparent areas of an image with a solid color @@ -17884,10 +17884,15 @@ export type components = { /** RemoveImagesFromBoardResult */ RemoveImagesFromBoardResult: { /** - * Removed Image Names + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Removed Images * @description The image names that were removed from their board */ - removed_image_names: string[]; + removed_images: string[]; }; /** * Resize Latents @@ -19688,6 +19693,19 @@ export type components = { */ type: "spandrel_image_to_image"; }; + /** StarredImagesResult */ + StarredImagesResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Starred Images + * @description The names of the images that were starred + */ + starred_images: string[]; + }; /** StarterModel */ StarterModel: { /** Description */ @@ -21293,6 +21311,19 @@ export type components = { */ type: "unsharp_mask"; }; + /** UnstarredImagesResult */ + UnstarredImagesResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Unstarred Images + * @description The names of the images that were unstarred + */ + unstarred_images: string[]; + }; /** Upscaler */ Upscaler: { /** @@ -23150,7 +23181,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["DeleteImagesResult"]; }; }; /** @description Validation Error */ @@ -23472,7 +23503,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DeleteImagesFromListResult"]; + "application/json": components["schemas"]["DeleteImagesResult"]; }; }; /** @description Validation Error */ @@ -23501,7 +23532,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DeleteImagesFromListResult"]; + "application/json": components["schemas"]["DeleteImagesResult"]; }; }; }; @@ -23525,7 +23556,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ImagesUpdatedFromListResult"]; + "application/json": components["schemas"]["StarredImagesResult"]; }; }; /** @description Validation Error */ @@ -23558,7 +23589,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ImagesUpdatedFromListResult"]; + "application/json": components["schemas"]["UnstarredImagesResult"]; }; }; /** @description Validation Error */ @@ -23879,7 +23910,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["AddImagesToBoardResult"]; }; }; /** @description Validation Error */ @@ -23912,7 +23943,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["RemoveImagesFromBoardResult"]; }; }; /** @description Validation Error */ From 4665f0df403008a54bc759350ca18df3110b5ceb Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:10:58 +1000 Subject: [PATCH 144/210] refactor(ui): use image names for selection instead of dtos Update the frontend to incorporate the previous changes to how image selection and general image identification is handled in the frontend. --- .../src/app/components/GlobalImageHotkeys.tsx | 4 +- .../addArchivedOrDeletedBoardListener.ts | 2 +- .../listeners/boardIdSelected.ts | 4 +- .../ensureImageIsSelectedListener.ts | 2 +- .../listeners/galleryImageClicked.ts | 26 +- .../listeners/galleryOffsetChanged.ts | 16 +- .../listeners/imageAddedToBoard.ts | 8 +- .../listeners/imagesStarred.ts | 35 +- .../listeners/imagesUnstarred.ts | 35 +- invokeai/frontend/web/src/app/store/store.ts | 3 +- .../web/src/common/hooks/useSelectorAsAtom.ts | 28 + .../components/ChangeBoardModal.tsx | 6 +- .../changeBoardModal/store/initialState.ts | 2 +- .../features/changeBoardModal/store/slice.ts | 7 +- .../features/changeBoardModal/store/types.ts | 4 +- .../components/DeleteImageModal.tsx | 2 +- .../features/deleteImageModal/store/state.ts | 57 +- .../dnd/DndDragPreviewMultipleImage.tsx | 11 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 26 +- .../components/Boards/DeleteBoardModal.tsx | 4 +- .../ImageMenuItemChangeBoard.tsx | 2 +- .../ImageContextMenu/ImageMenuItemDelete.tsx | 2 +- .../ImageMenuItemOpenInViewer.tsx | 2 +- .../ImageMenuItemSelectForCompare.tsx | 4 +- .../ImageMenuItemStarUnstar.tsx | 4 +- .../MultipleSelectionMenuItems.tsx | 32 +- .../components/ImageGrid/GalleryImage.tsx | 17 +- .../GalleryImageDeleteIconButton.tsx | 2 +- .../GalleryImageOpenInViewerIconButton.tsx | 2 +- .../ImageGrid/GalleryImageStarIconButton.tsx | 4 +- .../ImageGrid/GallerySelectionCountTag.tsx | 2 +- .../ImageViewer/CurrentImageButtons.tsx | 36 +- .../ImageViewer/CurrentImagePreview.tsx | 17 +- .../components/ImageViewer/ImageViewer.tsx | 12 +- .../ImageViewer/ImageViewerPanel.tsx | 89 ++- .../ToggleMetadataViewerButton.tsx | 12 +- .../components/ImageViewer/ViewerToolbar.tsx | 12 +- .../gallery/hooks/useGalleryNavigation.ts | 6 +- .../features/gallery/hooks/useImageActions.ts | 2 +- .../gallery/store/gallerySelectors.ts | 4 - .../features/gallery/store/gallerySlice.ts | 22 +- .../web/src/features/gallery/store/types.ts | 6 +- .../web/src/features/imageActions/actions.ts | 18 +- .../nodes/CurrentImage/CurrentImageNode.tsx | 4 +- .../PostProcessing/PostProcessingPopover.tsx | 11 +- .../ui/layouts/canvas-tab-auto-layout.tsx | 10 - .../ui/layouts/generate-tab-auto-layout.tsx | 10 - .../ui/layouts/upscaling-tab-auto-layout.tsx | 10 - .../ui/layouts/workflows-tab-auto-layout.tsx | 10 - .../web/src/services/api/endpoints/images.ts | 518 +++++++----------- .../services/events/onInvocationComplete.tsx | 2 +- 51 files changed, 551 insertions(+), 615 deletions(-) create mode 100644 invokeai/frontend/web/src/common/hooks/useSelectorAsAtom.ts diff --git a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx index c4826a9441..3752ef402f 100644 --- a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx @@ -7,11 +7,13 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react'; +import { useImageDTO } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; export const GlobalImageHotkeys = memo(() => { useAssertSingleton('GlobalImageHotkeys'); - const imageDTO = useAppSelector(selectLastSelectedImage); + const imageName = useAppSelector(selectLastSelectedImage); + const imageDTO = useImageDTO(imageName); if (!imageDTO) { return null; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts index a3831664c4..b031f1fcd6 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts @@ -25,7 +25,7 @@ export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartLis matcher: matchAnyBoardDeleted, effect: (action, { dispatch, getState }) => { const state = getState(); - const deletedBoardId = action.meta.arg.originalArgs; + const deletedBoardId = action.meta.arg.originalArgs.board_id; const { autoAddBoardId, selectedBoardId } = state.gallery; // If the deleted board was currently selected, we should reset the selected board to uncategorized diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 4ec0075dac..899e88a85c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -30,9 +30,9 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) const selectedImage = boardImagesData.items.find( (item) => item.image_name === action.payload.selectedImageName ); - dispatch(imageSelected(selectedImage || null)); + dispatch(imageSelected(selectedImage?.image_name ?? null)); } else if (boardImagesData) { - dispatch(imageSelected(boardImagesData.items[0] || null)); + dispatch(imageSelected(boardImagesData.items[0]?.image_name ?? null)); } else { // board has no images - deselect dispatch(imageSelected(null)); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts index f07fe68c1b..2e120d192c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener.ts @@ -9,7 +9,7 @@ export const addEnsureImageIsSelectedListener = (startAppListening: AppStartList effect: (action, { dispatch, getState }) => { const selection = getState().gallery.selection; if (selection.length === 0) { - dispatch(imageSelected(action.payload.items[0] ?? null)); + dispatch(imageSelected(action.payload.items[0]?.image_name ?? null)); } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index 5271d655d9..4356b77e69 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -2,11 +2,11 @@ import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { uniq } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; export const galleryImageClicked = createAction<{ - imageDTO: ImageDTO; + imageName: string; shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; @@ -28,7 +28,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen startAppListening({ actionCreator: galleryImageClicked, effect: (action, { dispatch, getState }) => { - const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload; + const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload; const state = getState(); const queryArgs = selectListImagesQueryArgs(state); const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state); @@ -42,31 +42,31 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen const selection = state.gallery.selection; if (altKey) { - if (state.gallery.imageToCompare?.image_name === imageDTO.image_name) { + if (state.gallery.imageToCompare === imageName) { dispatch(imageToCompareChanged(null)); } else { - dispatch(imageToCompareChanged(imageDTO)); + dispatch(imageToCompareChanged(imageName)); } } else if (shiftKey) { - const rangeEndImageName = imageDTO.image_name; - const lastSelectedImage = selection[selection.length - 1]?.image_name; + const rangeEndImageName = imageName; + const lastSelectedImage = selection.at(-1); const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage); const currentClickedIndex = imageDTOs.findIndex((n) => n.image_name === rangeEndImageName); if (lastClickedIndex > -1 && currentClickedIndex > -1) { // We have a valid range! const start = Math.min(lastClickedIndex, currentClickedIndex); const end = Math.max(lastClickedIndex, currentClickedIndex); - const imagesToSelect = imageDTOs.slice(start, end + 1); - dispatch(selectionChanged(selection.concat(imagesToSelect))); + const imagesToSelect = imageDTOs.slice(start, end + 1).map(({ image_name }) => image_name); + dispatch(selectionChanged(uniq(selection.concat(imagesToSelect)))); } } else if (ctrlKey || metaKey) { - if (selection.some((i) => i.image_name === imageDTO.image_name) && selection.length > 1) { - dispatch(selectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name))); + if (selection.some((n) => n === imageName) && selection.length > 1) { + dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName)))); } else { - dispatch(selectionChanged(selection.concat(imageDTO))); + dispatch(selectionChanged(uniq(selection.concat(imageName)))); } } else { - dispatch(selectionChanged([imageDTO])); + dispatch(selectionChanged([imageName])); } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts index 51095700e3..359fa647d9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts @@ -84,14 +84,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe if (offset < prevOffset) { // We've gone backwards const lastImage = imageDTOs[imageDTOs.length - 1]; - if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) { - dispatch(selectionChanged(lastImage ? [lastImage] : [])); + if (!selection.some((selectedImage) => selectedImage === lastImage?.image_name)) { + dispatch(selectionChanged(lastImage ? [lastImage.image_name] : [])); } } else { // We've gone forwards const firstImage = imageDTOs[0]; - if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) { - dispatch(selectionChanged(firstImage ? [firstImage] : [])); + if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) { + dispatch(selectionChanged(firstImage ? [firstImage.image_name] : [])); } } return; @@ -102,14 +102,14 @@ export const addGalleryOffsetChangedListener = (startAppListening: AppStartListe if (offset < prevOffset) { // We've gone backwards const lastImage = imageDTOs[imageDTOs.length - 1]; - if (lastImage && imageToCompare?.image_name !== lastImage.image_name) { - dispatch(imageToCompareChanged(lastImage)); + if (lastImage && imageToCompare !== lastImage.image_name) { + dispatch(imageToCompareChanged(lastImage.image_name)); } } else { // We've gone forwards const firstImage = imageDTOs[0]; - if (firstImage && imageToCompare?.image_name !== firstImage.image_name) { - dispatch(imageToCompareChanged(firstImage)); + if (firstImage && imageToCompare !== firstImage.image_name) { + dispatch(imageToCompareChanged(firstImage.image_name)); } } return; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts index 38e2127c0d..c01e562e4d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts @@ -8,16 +8,16 @@ export const addImageAddedToBoardFulfilledListener = (startAppListening: AppStar startAppListening({ matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled, effect: (action) => { - const { board_id, imageDTO } = action.meta.arg.originalArgs; - log.debug({ board_id, imageDTO }, 'Image added to board'); + const { board_id, image_name } = action.meta.arg.originalArgs; + log.debug({ board_id, image_name }, 'Image added to board'); }, }); startAppListening({ matcher: imagesApi.endpoints.addImageToBoard.matchRejected, effect: (action) => { - const { board_id, imageDTO } = action.meta.arg.originalArgs; - log.debug({ board_id, imageDTO }, 'Problem adding image to board'); + const { board_id, image_name } = action.meta.arg.originalArgs; + log.debug({ board_id, image_name }, 'Problem adding image to board'); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts index 0337b995f5..f4d014b055 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts @@ -1,30 +1,25 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; export const addImagesStarredListener = (startAppListening: AppStartListening) => { startAppListening({ matcher: imagesApi.endpoints.starImages.matchFulfilled, effect: (action, { dispatch, getState }) => { - const { updated_image_names: starredImages } = action.payload; - - const state = getState(); - - const { selection } = state.gallery; - const updatedSelection: ImageDTO[] = []; - - selection.forEach((selectedImageDTO) => { - if (starredImages.includes(selectedImageDTO.image_name)) { - updatedSelection.push({ - ...selectedImageDTO, - starred: true, - }); - } else { - updatedSelection.push(selectedImageDTO); - } - }); - dispatch(selectionChanged(updatedSelection)); + // const { updated_image_names: starredImages } = action.payload; + // const state = getState(); + // const { selection } = state.gallery; + // const updatedSelection: ImageDTO[] = []; + // selection.forEach((selectedImageDTO) => { + // if (starredImages.includes(selectedImageDTO.image_name)) { + // updatedSelection.push({ + // ...selectedImageDTO, + // starred: true, + // }); + // } else { + // updatedSelection.push(selectedImageDTO); + // } + // }); + // dispatch(selectionChanged(updatedSelection)); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts index ad6c26fd0c..00aaf28e91 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts @@ -1,30 +1,25 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; export const addImagesUnstarredListener = (startAppListening: AppStartListening) => { startAppListening({ matcher: imagesApi.endpoints.unstarImages.matchFulfilled, effect: (action, { dispatch, getState }) => { - const { updated_image_names: unstarredImages } = action.payload; - - const state = getState(); - - const { selection } = state.gallery; - const updatedSelection: ImageDTO[] = []; - - selection.forEach((selectedImageDTO) => { - if (unstarredImages.includes(selectedImageDTO.image_name)) { - updatedSelection.push({ - ...selectedImageDTO, - starred: false, - }); - } else { - updatedSelection.push(selectedImageDTO); - } - }); - dispatch(selectionChanged(updatedSelection)); + // const { updated_image_names: unstarredImages } = action.payload; + // const state = getState(); + // const { selection } = state.gallery; + // const updatedSelection: ImageDTO[] = []; + // selection.forEach((selectedImageDTO) => { + // if (unstarredImages.includes(selectedImageDTO.image_name)) { + // updatedSelection.push({ + // ...selectedImageDTO, + // starred: false, + // }); + // } else { + // updatedSelection.push(selectedImageDTO); + // } + // }); + // dispatch(selectionChanged(updatedSelection)); }, }); }; diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index ec757494f5..9397144751 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -39,6 +39,7 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware'; import type { JsonObject } from 'type-fest'; import { STORAGE_PREFIX } from './constants'; +import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware'; import { actionSanitizer } from './middleware/devtools/actionSanitizer'; import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { stateSanitizer } from './middleware/devtools/stateSanitizer'; @@ -176,7 +177,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) - // .concat(getDebugLoggerMiddleware()) + .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); diff --git a/invokeai/frontend/web/src/common/hooks/useSelectorAsAtom.ts b/invokeai/frontend/web/src/common/hooks/useSelectorAsAtom.ts new file mode 100644 index 0000000000..f333ce86d8 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useSelectorAsAtom.ts @@ -0,0 +1,28 @@ +import type { Selector } from '@reduxjs/toolkit'; +import type { RootState } from 'app/store/store'; +import { useAppStore } from 'app/store/storeHooks'; +import type { Atom, WritableAtom } from 'nanostores'; +import { atom } from 'nanostores'; +import { useEffect, useState } from 'react'; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export const useSelectorAsAtom = >(selector: T): Atom> => { + const store = useAppStore(); + const $atom = useState>>(() => atom>(selector(store.getState())))[0]; + + useEffect(() => { + const unsubscribe = store.subscribe(() => { + const prev = $atom.get(); + const next = selector(store.getState()); + if (prev !== next) { + $atom.set(next); + } + }); + + return () => { + unsubscribe(); + }; + }, [$atom, selector, store]); + + return $atom; +}; diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index 61993b8baa..839809914b 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -16,7 +16,7 @@ import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 's const selectImagesToChange = createMemoizedSelector( selectChangeBoardModalSlice, - (changeBoardModal) => changeBoardModal.imagesToChange + (changeBoardModal) => changeBoardModal.image_names ); const selectIsModalOpen = createSelector( @@ -57,10 +57,10 @@ const ChangeBoardModal = () => { } if (selectedBoard === 'none') { - removeImagesFromBoard({ imageDTOs: imagesToChange }); + removeImagesFromBoard({ image_names: imagesToChange }); } else { addImagesToBoard({ - imageDTOs: imagesToChange, + image_names: imagesToChange, board_id: selectedBoard, }); } diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/initialState.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/initialState.ts index e129c6f76f..0d7b2c3ec0 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/initialState.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/initialState.ts @@ -2,5 +2,5 @@ import type { ChangeBoardModalState } from './types'; export const initialState: ChangeBoardModalState = { isModalOpen: false, - imagesToChange: [], + image_names: [], }; diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts index 4b374b066a..c7c690d38c 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts @@ -1,7 +1,6 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; -import type { ImageDTO } from 'services/api/types'; import { initialState } from './initialState'; @@ -12,11 +11,11 @@ export const changeBoardModalSlice = createSlice({ isModalOpenChanged: (state, action: PayloadAction) => { state.isModalOpen = action.payload; }, - imagesToChangeSelected: (state, action: PayloadAction) => { - state.imagesToChange = action.payload; + imagesToChangeSelected: (state, action: PayloadAction) => { + state.image_names = action.payload; }, changeBoardReset: (state) => { - state.imagesToChange = []; + state.image_names = []; state.isModalOpen = false; }, }, diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/types.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/types.ts index f1a825480e..c46a7aa7fa 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/types.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/types.ts @@ -1,6 +1,4 @@ -import type { ImageDTO } from 'services/api/types'; - export type ChangeBoardModalState = { isModalOpen: boolean; - imagesToChange: ImageDTO[]; + image_names: string[]; }; diff --git a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx index 1e1213b1f2..f2aae77f0f 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx +++ b/invokeai/frontend/web/src/features/deleteImageModal/components/DeleteImageModal.tsx @@ -22,7 +22,7 @@ export const DeleteImageModal = memo(() => { return ( ({ - imageDTOs: [], + image_names: [], usagePerImage: [], usageSummary: { isControlLayerImage: false, @@ -54,21 +53,21 @@ const getInitialState = (): DeleteImagesModalState => ({ const $deleteModalState = atom(getInitialState()); -const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise => { +const deleteImagesWithDialog = async (image_names: string[]): Promise => { const { getState, dispatch } = getStore(); - const imageUsage = getImageUsageFromImageDTOs(imageDTOs, getState()); + const imageUsage = getImageUsageFromImageNames(image_names, getState()); const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState()); if (!shouldConfirmOnDelete && !isAnyImageInUse(imageUsage)) { // If we don't need to confirm and the images are not in use, delete them directly - await handleDeletions(imageDTOs, dispatch, getState); + await handleDeletions(image_names, dispatch, getState); } return new Promise((resolve, reject) => { $deleteModalState.set({ usagePerImage: imageUsage, usageSummary: getImageUsageSummary(imageUsage), - imageDTOs, + image_names, isOpen: true, resolve, reject, @@ -76,12 +75,12 @@ const deleteImagesWithDialog = async (imageDTOs: ImageDTO[]): Promise => { }); }; -const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, getState: AppGetState) => { +const handleDeletions = async (image_names: string[], dispatch: AppDispatch, getState: AppGetState) => { try { const state = getState(); - await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap(); + await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap(); - if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) { + if (intersection(state.gallery.selection, image_names).length > 0) { // Some selected images were deleted, need to select the next image const queryArgs = selectListImagesQueryArgs(state); const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state); @@ -93,11 +92,11 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get } // We need to reset the features where the image is in use - none of these work if their image(s) don't exist - for (const imageDTO of imageDTOs) { - deleteNodesImages(state, dispatch, imageDTO); - deleteControlLayerImages(state, dispatch, imageDTO); - deleteReferenceImages(state, dispatch, imageDTO); - deleteRasterLayerImages(state, dispatch, imageDTO); + for (const image_name of image_names) { + deleteNodesImages(state, dispatch, image_name); + deleteControlLayerImages(state, dispatch, image_name); + deleteReferenceImages(state, dispatch, image_name); + deleteRasterLayerImages(state, dispatch, image_name); } } catch { // no-op @@ -106,7 +105,7 @@ const handleDeletions = async (imageDTOs: ImageDTO[], dispatch: AppDispatch, get const confirmDeletion = async (dispatch: AppDispatch, getState: AppGetState) => { const state = $deleteModalState.get(); - await handleDeletions(state.imageDTOs, dispatch, getState); + await handleDeletions(state.image_names, dispatch, getState); state.resolve?.(); closeSilently(); }; @@ -142,8 +141,8 @@ export const useDeleteImageModalApi = () => { return api; }; -const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): ImageUsage[] => { - if (imageDTOs.length === 0) { +const getImageUsageFromImageNames = (image_names: string[], state: RootState): ImageUsage[] => { + if (image_names.length === 0) { return []; } @@ -152,7 +151,7 @@ const getImageUsageFromImageDTOs = (imageDTOs: ImageDTO[], state: RootState): Im const upscale = selectUpscaleSlice(state); const refImages = selectRefImagesSlice(state); - return imageDTOs.map(({ image_name }) => getImageUsage(nodes, canvas, upscale, refImages, image_name)); + return image_names.map((image_name) => getImageUsage(nodes, canvas, upscale, refImages, image_name)); }; const getImageUsageSummary = (imageUsage: ImageUsage[]): ImageUsage => ({ @@ -178,7 +177,7 @@ const isAnyImageInUse = (imageUsage: ImageUsage[]): boolean => ); // Some utils to delete images from different parts of the app -const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { +const deleteNodesImages = (state: RootState, dispatch: AppDispatch, image_name: string) => { const actions: Param0[] = []; state.nodes.present.nodes.forEach((node) => { if (!isInvocationNode(node)) { @@ -186,7 +185,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im } forEach(node.data.inputs, (input) => { - if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) { + if (isImageFieldInputInstance(input) && input.value?.image_name === image_name) { actions.push( fieldImageValueChanged({ nodeId: node.data.id, @@ -201,7 +200,7 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im fieldImageCollectionValueChanged({ nodeId: node.data.id, fieldName: input.name, - value: input.value?.filter((value) => value?.image_name !== imageDTO.image_name), + value: input.value?.filter((value) => value?.image_name !== image_name), }) ); } @@ -211,11 +210,11 @@ const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: Im actions.forEach(dispatch); }; -const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { +const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => { selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => { let shouldDelete = false; for (const obj of objects) { - if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) { + if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) { shouldDelete = true; break; } @@ -226,19 +225,19 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image }); }; -const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { +const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, image_name: string) => { selectReferenceImageEntities(state).forEach((entity) => { - if (entity.config.image?.image_name === imageDTO.image_name) { + if (entity.config.image?.image_name === image_name) { dispatch(refImageImageChanged({ id: entity.id, imageDTO: null })); } }); }; -const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { +const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, image_name: string) => { selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => { let shouldDelete = false; for (const obj of objects) { - if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === imageDTO.image_name) { + if (obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) { shouldDelete = true; break; } diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx index 7232d0fc2a..2633d9e508 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx @@ -6,10 +6,9 @@ import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } f import { memo } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; -import type { ImageDTO } from 'services/api/types'; import type { Param0 } from 'tsafe'; -const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[] }) => { +const DndDragPreviewMultipleImage = memo(({ image_names }: { image_names: string[] }) => { const { t } = useTranslation(); return ( - {imageDTOs.length} + {image_names.length} {t('parameters.images')} ); @@ -32,11 +31,11 @@ DndDragPreviewMultipleImage.displayName = 'DndDragPreviewMultipleImage'; export type DndDragPreviewMultipleImageState = { type: 'multiple-image'; container: HTMLElement; - imageDTOs: ImageDTO[]; + image_names: string[]; }; export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageState) => - createPortal(, arg.container); + createPortal(, arg.container); type SetMultipleDragPreviewArg = { multipleImageDndData: MultipleImageDndSourceData; @@ -52,7 +51,7 @@ export const setMultipleImageDragPreview = ({ const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs; setCustomNativeDragPreview({ render({ container }) { - setDragPreviewState({ type: 'multiple-image', container, imageDTOs: multipleImageDndData.payload.imageDTOs }); + setDragPreviewState({ type: 'multiple-image', container, image_names: multipleImageDndData.payload.image_names }); return () => setDragPreviewState(null); }, nativeSetDragImage, diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index df768f4ec6..5247b82b11 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -87,7 +87,7 @@ const _multipleImage = buildTypeAndKey('multiple-image'); export type MultipleImageDndSourceData = DndData< typeof _multipleImage.type, typeof _multipleImage.key, - { imageDTOs: ImageDTO[]; boardId: BoardId } + { image_names: string[]; board_id: BoardId } >; export const multipleImageDndSource: DndSource = { ..._multipleImage, @@ -305,7 +305,7 @@ export const addImagesToNodeImageFieldCollectionDndTarget: DndTarget< if (singleImageDndSource.typeGuard(sourceData)) { newValue.push({ image_name: sourceData.payload.imageDTO.image_name }); } else { - newValue.push(...sourceData.payload.imageDTOs.map(({ image_name }) => ({ image_name }))); + newValue.push(...sourceData.payload.image_names.map((image_name) => ({ image_name }))); } dispatch(fieldImageCollectionValueChanged({ ...fieldIdentifier, value: newValue })); @@ -330,17 +330,17 @@ export const setComparisonImageDndTarget: DndTarget { const { imageDTO } = sourceData.payload; - setComparisonImage({ imageDTO, dispatch }); + setComparisonImage({ image_name: imageDTO.image_name, dispatch }); }, }; //#endregion @@ -450,7 +450,7 @@ export const addImageToBoardDndTarget: DndTarget< return currentBoard !== destinationBoard; } if (multipleImageDndSource.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; + const currentBoard = sourceData.payload.board_id; const destinationBoard = targetData.payload.boardId; return currentBoard !== destinationBoard; } @@ -460,13 +460,13 @@ export const addImageToBoardDndTarget: DndTarget< if (singleImageDndSource.typeGuard(sourceData)) { const { imageDTO } = sourceData.payload; const { boardId } = targetData.payload; - addImagesToBoard({ imageDTOs: [imageDTO], boardId, dispatch }); + addImagesToBoard({ image_names: [imageDTO.image_name], boardId, dispatch }); } if (multipleImageDndSource.typeGuard(sourceData)) { - const { imageDTOs } = sourceData.payload; + const { image_names } = sourceData.payload; const { boardId } = targetData.payload; - addImagesToBoard({ imageDTOs, boardId, dispatch }); + addImagesToBoard({ image_names, boardId, dispatch }); } }, }; @@ -494,7 +494,7 @@ export const removeImageFromBoardDndTarget: DndTarget< } if (multipleImageDndSource.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; + const currentBoard = sourceData.payload.board_id; return currentBoard !== 'none'; } @@ -503,12 +503,12 @@ export const removeImageFromBoardDndTarget: DndTarget< handler: ({ sourceData, dispatch }) => { if (singleImageDndSource.typeGuard(sourceData)) { const { imageDTO } = sourceData.payload; - removeImagesFromBoard({ imageDTOs: [imageDTO], dispatch }); + removeImagesFromBoard({ image_names: [imageDTO.image_name], dispatch }); } if (multipleImageDndSource.typeGuard(sourceData)) { - const { imageDTOs } = sourceData.payload; - removeImagesFromBoard({ imageDTOs, dispatch }); + const { image_names } = sourceData.payload; + removeImagesFromBoard({ image_names, dispatch }); } }, }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx index fa016a6b46..b1a9b6129e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx @@ -91,7 +91,7 @@ const DeleteBoardModal = () => { if (!boardToDelete || boardToDelete === 'none') { return; } - deleteBoardOnly(boardToDelete.board_id); + deleteBoardOnly({ board_id: boardToDelete.board_id }); $boardToDelete.set(null); }, [boardToDelete, deleteBoardOnly]); @@ -99,7 +99,7 @@ const DeleteBoardModal = () => { if (!boardToDelete || boardToDelete === 'none') { return; } - deleteBoardAndImages(boardToDelete.board_id); + deleteBoardAndImages({ board_id: boardToDelete.board_id }); $boardToDelete.set(null); }, [boardToDelete, deleteBoardAndImages]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx index 200b08b4c2..331ccb5538 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx @@ -12,7 +12,7 @@ export const ImageMenuItemChangeBoard = memo(() => { const imageDTO = useImageDTOContext(); const onClick = useCallback(() => { - dispatch(imagesToChangeSelected([imageDTO])); + dispatch(imagesToChangeSelected([imageDTO.image_name])); dispatch(isModalOpenChanged(true)); }, [dispatch, imageDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx index fcddd75483..2708381b19 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx @@ -12,7 +12,7 @@ export const ImageMenuItemDelete = memo(() => { const onClick = useCallback(async () => { try { - await deleteImageModal.delete([imageDTO]); + await deleteImageModal.delete([imageDTO.image_name]); } catch { // noop; } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx index f6753316b6..cae757d3fd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx @@ -12,7 +12,7 @@ export const ImageMenuItemOpenInViewer = memo(() => { const imageDTO = useImageDTOContext(); const onClick = useCallback(() => { dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(imageDTO)); + dispatch(imageSelected(imageDTO.image_name)); // TODO: figure out how to select the closest image viewer... }, [dispatch, imageDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx index a95bb0e765..129671819f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx @@ -12,13 +12,13 @@ export const ImageMenuItemSelectForCompare = memo(() => { const dispatch = useAppDispatch(); const imageDTO = useImageDTOContext(); const selectMaySelectForCompare = useMemo( - () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name !== imageDTO.image_name), + () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare !== imageDTO.image_name), [imageDTO.image_name] ); const maySelectForCompare = useAppSelector(selectMaySelectForCompare); const onClick = useCallback(() => { - dispatch(imageToCompareChanged(imageDTO)); + dispatch(imageToCompareChanged(imageDTO.image_name)); }, [dispatch, imageDTO]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx index a82e8ed2a8..fd89a328d4 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx @@ -16,13 +16,13 @@ export const ImageMenuItemStarUnstar = memo(() => { const starImage = useCallback(() => { if (imageDTO) { - starImages({ imageDTOs: [imageDTO] }); + starImages({ image_names: [imageDTO.image_name] }); } }, [starImages, imageDTO]); const unstarImage = useCallback(() => { if (imageDTO) { - unstarImages({ imageDTOs: [imageDTO] }); + unstarImages({ image_names: [imageDTO.image_name] }); } }, [unstarImages, imageDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx index 8e83889542..c4c232dc13 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiDownloadSimpleBold, PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi'; import { @@ -37,37 +37,25 @@ const MultipleSelectionMenuItems = () => { }, [deleteImageModal, selection]); const handleStarSelection = useCallback(() => { - starImages({ imageDTOs: selection }); + starImages({ image_names: selection }); }, [starImages, selection]); const handleUnstarSelection = useCallback(() => { - unstarImages({ imageDTOs: selection }); + unstarImages({ image_names: selection }); }, [unstarImages, selection]); const handleBulkDownload = useCallback(() => { - bulkDownload({ image_names: selection.map((img) => img.image_name) }); + bulkDownload({ image_names: selection }); }, [selection, bulkDownload]); - const areAllStarred = useMemo(() => { - return selection.every((img) => img.starred); - }, [selection]); - - const areAllUnstarred = useMemo(() => { - return selection.every((img) => !img.starred); - }, [selection]); - return ( <> - {areAllStarred && ( - } onClickCapture={handleUnstarSelection}> - {customStarUi ? customStarUi.off.text : `Unstar All`} - - )} - {(areAllUnstarred || (!areAllStarred && !areAllUnstarred)) && ( - } onClickCapture={handleStarSelection}> - {customStarUi ? customStarUi.on.text : `Star All`} - - )} + } onClickCapture={handleUnstarSelection}> + {customStarUi ? customStarUi.off.text : `Unstar All`} + + } onClickCapture={handleStarSelection}> + {customStarUi ? customStarUi.on.text : `Star All`} + {isBulkDownloadEnabled && ( } onClickCapture={handleBulkDownload}> {t('gallery.downloadSelection')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index e54f12d82c..2067d75f49 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -93,7 +93,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { const ref = useRef(null); const dndId = useId(); const selectIsSelectedForCompare = useMemo( - () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name), + () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare === imageDTO.image_name), [imageDTO.image_name] ); const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare); @@ -101,7 +101,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { () => createSelector(selectGallerySlice, (gallery) => { for (const selectedImage of gallery.selection) { - if (selectedImage.image_name === imageDTO.image_name) { + if (selectedImage === imageDTO.image_name) { return true; } } @@ -126,11 +126,11 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // multi-image drag. if ( gallery.selection.length > 1 && - gallery.selection.find(({ image_name }) => image_name === imageDTO.image_name) !== undefined + gallery.selection.find((image_name) => image_name === imageDTO.image_name) !== undefined ) { return multipleImageDndSource.getData({ - imageDTOs: gallery.selection, - boardId: gallery.selectedBoardId, + image_names: gallery.selection, + board_id: gallery.selectedBoardId, }); } @@ -167,7 +167,10 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { onDragStart: ({ source }) => { // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the // selection. This is called for all drag events. - if (multipleImageDndSource.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) { + if ( + multipleImageDndSource.typeGuard(source.data) && + source.data.payload.image_names.includes(imageDTO.image_name) + ) { setIsDragging(true); } }, @@ -193,7 +196,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { (e) => { store.dispatch( galleryImageClicked({ - imageDTO, + imageName: imageDTO.image_name, shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, metaKey: e.metaKey, diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx index 574890f0ed..137bb3e6fd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx @@ -22,7 +22,7 @@ export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => { if (!imageDTO) { return; } - deleteImageModal.delete([imageDTO]); + deleteImageModal.delete([imageDTO.image_name]); }, [deleteImageModal, imageDTO] ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx index bd742430ea..1de72b4026 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -19,7 +19,7 @@ export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => const onClick = useCallback(() => { dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(imageDTO)); + dispatch(imageSelected(imageDTO.image_name)); focusPanel(VIEWER_PANEL_ID); }, [dispatch, focusPanel, imageDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx index 60eb497106..0306c2095d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx @@ -17,9 +17,9 @@ export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => { const toggleStarredState = useCallback(() => { if (imageDTO.starred) { - unstarImages({ imageDTOs: [imageDTO] }); + unstarImages({ image_names: [imageDTO.image_name] }); } else { - starImages({ imageDTOs: [imageDTO] }); + starImages({ image_names: [imageDTO.image_name] }); } }, [starImages, unstarImages, imageDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index 1ee42cf5cb..e76936c090 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -19,7 +19,7 @@ export const GallerySelectionCountTag = memo(() => { const isGalleryFocused = useIsRegionFocused('gallery'); const onSelectPage = useCallback(() => { - dispatch(selectionChanged([...selection, ...imageDTOs])); + dispatch(selectionChanged([...selection, ...imageDTOs.map(({ image_name }) => image_name)])); }, [dispatch, selection, imageDTOs]); useRegisteredHotkeys({ diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 9332a2bfaf..ee2fe1e884 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -1,6 +1,5 @@ import { Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton'; @@ -10,6 +9,7 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors import { $hasTemplates } from 'features/nodes/store/nodesSlice'; import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -21,14 +21,22 @@ import { PiQuotesBold, PiRulerBold, } from 'react-icons/pi'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { useImageDTO } from 'services/api/endpoints/images'; + +import { useImageViewerContext } from './ImageViewerPanel'; export const CurrentImageButtons = memo(() => { - const lastSelectedImage = useAppSelector(selectLastSelectedImage); - const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken); const { t } = useTranslation(); + const ctx = useImageViewerContext(); + const hasProgressImage = useStore(ctx.$hasProgressImage); + const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + + const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer; + + const imageName = useAppSelector(selectLastSelectedImage); + const imageDTO = useImageDTO(imageName); const hasTemplates = useStore($hasTemplates); - const imageActions = useImageActions(imageDTO ?? null); + const imageActions = useImageActions(imageDTO); const isStaging = useAppSelector(selectIsStaging); const isUpscalingEnabled = useFeatureStatus('upscaling'); @@ -39,7 +47,7 @@ export const CurrentImageButtons = memo(() => { as={IconButton} aria-label={t('parameters.imageActions')} tooltip={t('parameters.imageActions')} - isDisabled={!imageDTO} + isDisabled={isDisabledOverride || !imageDTO} variant="link" alignSelf="stretch" icon={} @@ -53,7 +61,7 @@ export const CurrentImageButtons = memo(() => { icon={} tooltip={`${t('nodes.loadWorkflow')} (W)`} aria-label={`${t('nodes.loadWorkflow')} (W)`} - isDisabled={!imageDTO || !imageActions.hasWorkflow || !hasTemplates} + isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasWorkflow || !hasTemplates} variant="link" alignSelf="stretch" onClick={imageActions.loadWorkflow} @@ -62,7 +70,7 @@ export const CurrentImageButtons = memo(() => { icon={} tooltip={`${t('parameters.remixImage')} (R)`} aria-label={`${t('parameters.remixImage')} (R)`} - isDisabled={!imageDTO || !imageActions.hasMetadata} + isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata} variant="link" alignSelf="stretch" onClick={imageActions.remix} @@ -71,7 +79,7 @@ export const CurrentImageButtons = memo(() => { icon={} tooltip={`${t('parameters.usePrompt')} (P)`} aria-label={`${t('parameters.usePrompt')} (P)`} - isDisabled={!imageDTO || !imageActions.hasPrompts} + isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasPrompts} variant="link" alignSelf="stretch" onClick={imageActions.recallPrompts} @@ -80,7 +88,7 @@ export const CurrentImageButtons = memo(() => { icon={} tooltip={`${t('parameters.useSeed')} (S)`} aria-label={`${t('parameters.useSeed')} (S)`} - isDisabled={!imageDTO || !imageActions.hasSeed} + isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasSeed} variant="link" alignSelf="stretch" onClick={imageActions.recallSeed} @@ -92,23 +100,23 @@ export const CurrentImageButtons = memo(() => { variant="link" alignSelf="stretch" onClick={imageActions.recallSize} - isDisabled={!imageDTO || isStaging} + isDisabled={isDisabledOverride || !imageDTO || isStaging} /> } tooltip={`${t('parameters.useAll')} (A)`} aria-label={`${t('parameters.useAll')} (A)`} - isDisabled={!imageDTO || !imageActions.hasMetadata} + isDisabled={isDisabledOverride || !imageDTO || !imageActions.hasMetadata} variant="link" alignSelf="stretch" onClick={imageActions.recallAll} /> - {isUpscalingEnabled && } + {isUpscalingEnabled && } - + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 7d0ef2e1d4..652ebd9381 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -6,7 +6,7 @@ import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; -import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors'; +import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; @@ -20,6 +20,8 @@ import { ProgressIndicator } from './ProgressIndicator2'; export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); + const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + const socket = useStore($socket); const [progressEvent, setProgressEvent] = useState(null); const [progressImage, setProgressImage] = useState(null); @@ -65,6 +67,8 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) } }, [imageDTO, progressEvent]); + const withProgress = shouldShowProgressInViewer && progressEvent && progressImage; + return ( - {imageDTO ? ( + {imageDTO && ( - ) : ( - )} - {progressEvent && progressImage && ( + {!imageDTO && } + {withProgress && ( @@ -90,9 +93,9 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) )} - {imageDTO && } + {imageDTO && !withProgress && } - {shouldShowImageDetails && imageDTO && ( + {shouldShowImageDetails && imageDTO && !withProgress && ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 6ca2e1b8c5..2ff613ef40 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -1,11 +1,10 @@ -import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common'; import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview'; import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison'; -import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import { memo } from 'react'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { useImageDTO } from 'services/api/endpoints/images'; // type Props = { // closeButton?: ReactNode; @@ -28,9 +27,10 @@ import { useGetImageDTOQuery } from 'services/api/endpoints/images'; // }; export const ImageViewer = memo(() => { - const lastSelectedImageName = useAppSelector(selectLastSelectedImageName); - const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken); - const comparisonImageDTO = useAppSelector(selectImageToCompare); + const lastSelectedImageName = useAppSelector(selectLastSelectedImage); + const lastSelectedImageDTO = useImageDTO(lastSelectedImageName); + const comparisonImageName = useAppSelector(selectImageToCompare); + const comparisonImageDTO = useImageDTO(comparisonImageName); if (lastSelectedImageDTO && comparisonImageDTO) { return ; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx index 765a6e0909..bc7cda66b2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx @@ -1,13 +1,86 @@ import { Divider, Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar'; -import { memo } from 'react'; +import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; +import { type Atom, atom, computed } from 'nanostores'; +import type { PropsWithChildren } from 'react'; +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { ImageDTO, S } from 'services/api/types'; +import { $socket } from 'services/events/stores'; +import { assert } from 'tsafe'; -export const ImageViewerPanel = memo(() => ( - - - - - -)); +export const ImageViewerPanel = memo(() => { + return ( + + + + + + + + ); +}); ImageViewerPanel.displayName = 'ImageViewerPanel'; + +type ImageViewerContextValue = { + $progressEvent: Atom; + $progressImage: Atom; + $hasProgressImage: Atom; + onLoadImage: (imageDTO: ImageDTO) => void; +}; + +const ImageViewerContext = createContext(null); + +const ImageViewerContextProvider = memo((props: PropsWithChildren) => { + const socket = useStore($socket); + const $progressEvent = useState(() => atom(null))[0]; + const $progressImage = useState(() => atom(null))[0]; + const $hasProgressImage = useState(() => computed($progressImage, (progressImage) => progressImage !== null))[0]; + + useEffect(() => { + if (!socket) { + return; + } + + const onInvocationProgress = (data: S['InvocationProgressEvent']) => { + $progressEvent.set(data); + if (data.image) { + $progressImage.set(data.image); + } + }; + + socket.on('invocation_progress', onInvocationProgress); + + return () => { + socket.off('invocation_progress', onInvocationProgress); + }; + }, [$progressEvent, $progressImage, socket]); + + const onLoadImage = useCallback( + (imageDTO: ImageDTO) => { + const progressEvent = $progressEvent.get(); + if (!progressEvent || !imageDTO) { + return; + } + if (progressEvent.session_id === imageDTO.session_id) { + $progressImage.set(null); + } + }, + [$progressEvent, $progressImage] + ); + + const value = useMemo( + () => ({ $progressEvent, $progressImage, $hasProgressImage, onLoadImage }), + [$hasProgressImage, $progressEvent, $progressImage, onLoadImage] + ); + + return {props.children}; +}); +ImageViewerContextProvider.displayName = 'ImageViewerContextProvider'; + +export const useImageViewerContext = () => { + const value = useContext(ImageViewerContext); + assert(value !== null, 'useImageViewerContext must be used within a ImageViewerContextProvider'); + return value; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx index e40cb510c6..1824ca1353 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx @@ -1,15 +1,24 @@ import { IconButton } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors'; +import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import { setShouldShowImageDetails } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiInfoBold } from 'react-icons/pi'; +import { useImageViewerContext } from './ImageViewerPanel'; + export const ToggleMetadataViewerButton = memo(() => { const dispatch = useAppDispatch(); + const ctx = useImageViewerContext(); + const hasProgressImage = useStore(ctx.$hasProgressImage); + const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + + const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer; + const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); const imageDTO = useAppSelector(selectLastSelectedImage); const { t } = useTranslation(); @@ -35,6 +44,7 @@ export const ToggleMetadataViewerButton = memo(() => { alignSelf="stretch" colorScheme={shouldShowImageDetails ? 'invokeBlue' : 'base'} data-testid="toggle-show-metadata-button" + isDisabled={isDisabledOverride} /> ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx index 1c5a67bae8..12167f5f61 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx @@ -1,16 +1,18 @@ -import { ButtonGroup, Flex } from '@invoke-ai/ui-library'; +import { Flex, Spacer } from '@invoke-ai/ui-library'; import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton'; import { memo } from 'react'; import { CurrentImageButtons } from './CurrentImageButtons'; +import { ToggleProgressButton } from './ToggleProgressButton'; export const ViewerToolbar = memo(() => { return ( - - - - + + + + + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts index 26cf31cc89..a25e96f0cc 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts @@ -177,7 +177,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => { if (imageDTOs.length === 0 || !lastSelectedImage) { return 0; } - return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage.image_name); + return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage); }, [imageDTOs, lastSelectedImage]); const handleNavigation = useCallback( @@ -187,9 +187,9 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => { return; } if (alt) { - dispatch(imageToCompareChanged(image)); + dispatch(imageToCompareChanged(image.image_name)); } else { - dispatch(imageSelected(image)); + dispatch(imageSelected(image.image_name)); } scrollToImage(image.image_name, index); }, diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts index 90cc41e689..1582f0d24b 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts @@ -199,7 +199,7 @@ export const useImageActions = (imageDTO: ImageDTO | null) => { if (!imageDTO) { return; } - deleteImageModal.delete([imageDTO]); + deleteImageModal.delete([imageDTO.image_name]); }, [deleteImageModal, imageDTO]); return { diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index 6a5ff11351..a3f10d47e9 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -8,10 +8,6 @@ import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types'; export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); -export const selectLastSelectedImageName = createSelector( - selectGallerySlice, - (gallery) => gallery.selection.at(-1)?.image_name -); export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit); export const selectListImagesQueryArgs = createMemoizedSelector( diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 94afbe1f90..c21ae398cb 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -1,8 +1,8 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { PersistConfig, RootState } from 'app/store/store'; -import { isEqual, uniqBy } from 'lodash-es'; -import type { BoardRecordOrderBy, ImageDTO } from 'services/api/types'; +import { isEqual, uniq } from 'lodash-es'; +import type { BoardRecordOrderBy } from 'services/api/types'; import type { BoardId, ComparisonMode, GalleryState, GalleryView, OrderDir } from './types'; @@ -33,14 +33,14 @@ export const gallerySlice = createSlice({ name: 'gallery', initialState: initialGalleryState, reducers: { - imageSelected: (state, action: PayloadAction) => { + imageSelected: (state, action: PayloadAction) => { // Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent // unnecessary re-renders of the gallery. - const selectedImage = action.payload; + const selectedImageName = action.payload; // If we got `null`, clear the selection - if (!selectedImage) { + if (!selectedImageName) { // But only if we have images selected if (state.selection.length > 0) { state.selection = []; @@ -50,24 +50,24 @@ export const gallerySlice = createSlice({ // If we have multiple images selected, clear the selection and select the new image if (state.selection.length !== 1) { - state.selection = [selectedImage]; + state.selection = [selectedImageName]; return; } // If the selected image is different from the current selection, clear the selection and select the new image - if (!isEqual(state.selection[0], selectedImage)) { - state.selection = [selectedImage]; + if (!isEqual(state.selection[0], selectedImageName)) { + state.selection = [selectedImageName]; return; } // Else we have the same image selected, do nothing }, - selectionChanged: (state, action: PayloadAction) => { + selectionChanged: (state, action: PayloadAction) => { // Let's be efficient here and not update the selection unless it has actually changed. This helps to prevent // unnecessary re-renders of the gallery. // Remove duplicates from the selection - const newSelection = uniqBy(action.payload, (i) => i.image_name); + const newSelection = uniq(action.payload); // If the new selection has a different length, update the selection if (newSelection.length !== state.selection.length) { @@ -83,7 +83,7 @@ export const gallerySlice = createSlice({ // Else we have the same selection, do nothing }, - imageToCompareChanged: (state, action: PayloadAction) => { + imageToCompareChanged: (state, action: PayloadAction) => { state.imageToCompare = action.payload; }, comparisonModeChanged: (state, action: PayloadAction) => { diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index 2da48031b4..ad901e6d78 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -1,4 +1,4 @@ -import type { BoardRecordOrderBy, ImageCategory, ImageDTO } from 'services/api/types'; +import type { BoardRecordOrderBy, ImageCategory } from 'services/api/types'; export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other']; @@ -10,7 +10,7 @@ export type ComparisonFit = 'contain' | 'fill'; export type OrderDir = 'ASC' | 'DESC'; export type GalleryState = { - selection: ImageDTO[]; + selection: string[]; shouldAutoSwitch: boolean; autoAssignBoardOnClick: boolean; autoAddBoardId: BoardId; @@ -24,7 +24,7 @@ export type GalleryState = { orderDir: OrderDir; searchTerm: string; alwaysShowImageSizeBadge: boolean; - imageToCompare: ImageDTO | null; + imageToCompare: string | null; comparisonMode: ComparisonMode; comparisonFit: ComparisonFit; shouldShowArchivedBoards: boolean; diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index 2d91dbc177..296c9a639d 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -71,9 +71,9 @@ export const setNodeImageFieldImage = (arg: { dispatch(fieldImageValueChanged({ ...fieldIdentifier, value: imageDTO })); }; -export const setComparisonImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => { - const { imageDTO, dispatch } = arg; - dispatch(imageToCompareChanged(imageDTO)); +export const setComparisonImage = (arg: { image_name: string; dispatch: AppDispatch }) => { + const { image_name, dispatch } = arg; + dispatch(imageToCompareChanged(image_name)); }; export const createNewCanvasEntityFromImage = (arg: { @@ -292,14 +292,14 @@ export const replaceCanvasEntityObjectsWithImage = (arg: { ); }; -export const addImagesToBoard = (arg: { imageDTOs: ImageDTO[]; boardId: BoardId; dispatch: AppDispatch }) => { - const { imageDTOs, boardId, dispatch } = arg; - dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false })); +export const addImagesToBoard = (arg: { image_names: string[]; boardId: BoardId; dispatch: AppDispatch }) => { + const { image_names, boardId, dispatch } = arg; + dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ image_names, board_id: boardId }, { track: false })); dispatch(selectionChanged([])); }; -export const removeImagesFromBoard = (arg: { imageDTOs: ImageDTO[]; dispatch: AppDispatch }) => { - const { imageDTOs, dispatch } = arg; - dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false })); +export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: AppDispatch }) => { + const { image_names, dispatch } = arg; + dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false })); dispatch(selectionChanged([])); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx index d89e452dfd..1a1001ab9f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx @@ -13,11 +13,13 @@ import { motion } from 'framer-motion'; import type { CSSProperties, PropsWithChildren } from 'react'; import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useImageDTO } from 'services/api/endpoints/images'; import { $lastProgressEvent } from 'services/events/stores'; const CurrentImageNode = (props: NodeProps) => { - const imageDTO = useAppSelector(selectLastSelectedImage); + const image_name = useAppSelector(selectLastSelectedImage); const lastProgressEvent = useStore($lastProgressEvent); + const imageDTO = useImageDTO(image_name); if (lastProgressEvent?.image) { return ( diff --git a/invokeai/frontend/web/src/features/parameters/components/PostProcessing/PostProcessingPopover.tsx b/invokeai/frontend/web/src/features/parameters/components/PostProcessing/PostProcessingPopover.tsx index 2d8503775d..15c5e19e7f 100644 --- a/invokeai/frontend/web/src/features/parameters/components/PostProcessing/PostProcessingPopover.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/PostProcessing/PostProcessingPopover.tsx @@ -21,10 +21,10 @@ import { Trans, useTranslation } from 'react-i18next'; import { PiFrameCornersBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; -type Props = { imageDTO?: ImageDTO }; +type Props = { imageDTO: ImageDTO | null; isDisabled: boolean }; export const PostProcessingPopover = memo((props: Props) => { - const { imageDTO } = props; + const { imageDTO, isDisabled } = props; const dispatch = useAppDispatch(); const postProcessingModel = useAppSelector(selectPostProcessingModel); const inProgress = useIsQueueMutationInProgress(); @@ -49,6 +49,7 @@ export const PostProcessingPopover = memo((props: Props) => { aria-label={t('parameters.postProcessing')} variant="link" alignSelf="stretch" + isDisabled={isDisabled} /> @@ -56,7 +57,11 @@ export const PostProcessingPopover = memo((props: Props) => { {!postProcessingModel && } - 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 c82a7832f5..7e6004be1d 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 @@ -75,16 +75,6 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { referencePanel: LAUNCHPAD_PANEL_ID, }, }); - api.addPanel({ - id: PROGRESS_PANEL_ID, - component: PROGRESS_PANEL_ID, - title: 'Generation Progress', - tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, - position: { - direction: 'within', - referencePanel: LAUNCHPAD_PANEL_ID, - }, - }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); }; 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 b96059aa32..9041c9f444 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 @@ -60,16 +60,6 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { referencePanel: LAUNCHPAD_PANEL_ID, }, }); - api.addPanel({ - id: PROGRESS_PANEL_ID, - component: PROGRESS_PANEL_ID, - title: 'Generation Progress', - tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, - position: { - direction: 'within', - referencePanel: LAUNCHPAD_PANEL_ID, - }, - }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); }; 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 22f2b26da2..ec2e378a87 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 @@ -60,16 +60,6 @@ const initializeCenterLayout = (api: DockviewApi) => { referencePanel: LAUNCHPAD_PANEL_ID, }, }); - api.addPanel({ - id: PROGRESS_PANEL_ID, - component: PROGRESS_PANEL_ID, - title: 'Generation Progress', - tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, - position: { - direction: 'within', - referencePanel: LAUNCHPAD_PANEL_ID, - }, - }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); }; 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 07974fa3fb..1c11a50ebb 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 @@ -73,16 +73,6 @@ const initializeCenterPanelLayout = (api: DockviewApi) => { referencePanel: LAUNCHPAD_PANEL_ID, }, }); - api.addPanel({ - id: PROGRESS_PANEL_ID, - component: PROGRESS_PANEL_ID, - title: 'Generation Progress', - tabComponent: TAB_WITH_PROGRESS_INDICATOR_ID, - position: { - direction: 'within', - referencePanel: LAUNCHPAD_PANEL_ID, - }, - }); api.getPanel(LAUNCHPAD_PANEL_ID)?.api.setActive(); }; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 30aad43449..0938d4a2a9 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -1,11 +1,9 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { $authToken } from 'app/store/nanostores/authToken'; import { getStore } from 'app/store/nanostores/store'; -import type { BoardId } from 'features/gallery/store/types'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import { uniqBy } from 'lodash-es'; import type { components, paths } from 'services/api/schema'; import type { - DeleteBoardResult, GraphAndWorkflowResponse, ImageDTO, ImageUploadEntryRequest, @@ -15,7 +13,6 @@ import type { UploadImageArg, } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; -import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -94,141 +91,77 @@ export const imagesApi = api.injectEndpoints({ query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}/workflow`) }), providesTags: (result, error, image_name) => [{ type: 'ImageWorkflow', id: image_name }], }), - deleteImage: build.mutation({ + deleteImage: build.mutation< + paths['/api/v1/images/i/{image_name}']['delete']['responses']['200']['content']['application/json'], + paths['/api/v1/images/i/{image_name}']['delete']['parameters']['path'] + >({ query: ({ image_name }) => ({ url: buildImagesUrl(`i/${image_name}`), method: 'DELETE', }), - invalidatesTags: (result, error, imageDTO) => { - const categories = getCategories(imageDTO); - const boardId = imageDTO.board_id ?? 'none'; - - return [ - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: boardId, - categories, - }), - }, - { - type: 'Board', - id: boardId, - }, - { - type: 'BoardImagesTotal', - id: boardId, - }, - ]; - }, - }), - - deleteImages: build.mutation({ - query: ({ imageDTOs }) => { - const image_names = imageDTOs.map((imageDTO) => imageDTO.image_name); - return { - url: buildImagesUrl('delete'), - method: 'POST', - body: { - image_names, - }, - }; - }, - invalidatesTags: (result, error, { imageDTOs }) => { - const tags: ApiTagDescription[] = []; - for (const imageDTO of imageDTOs) { - const categories = getCategories(imageDTO); - const boardId = imageDTO.board_id ?? 'none'; - - tags.push( - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: boardId, - categories, - }), - }, - { - type: 'Board', - id: boardId, - }, - { - type: 'BoardImagesTotal', - id: boardId, - } - ); + invalidatesTags: (result) => { + if (!result) { + return []; } - - const dedupedTags = uniqBy(tags, stableHash); - return dedupedTags; + // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries + // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags + // will force those queries to re-fetch, and the requests will of course 404. + return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards); }, }), - deleteUncategorizedImages: build.mutation({ + deleteImages: build.mutation< + paths['/api/v1/images/delete']['post']['responses']['200']['content']['application/json'], + paths['/api/v1/images/delete']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => ({ + url: buildImagesUrl('delete'), + method: 'POST', + body, + }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries + // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags + // will force those queries to re-fetch, and the requests will of course 404. + return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards); + }, + }), + deleteUncategorizedImages: build.mutation< + paths['/api/v1/images/uncategorized']['delete']['responses']['200']['content']['application/json'], + void + >({ query: () => ({ url: buildImagesUrl('uncategorized'), method: 'DELETE' }), invalidatesTags: (result) => { - if (result && result.deleted_images.length > 0) { - const boardId = 'none'; - - const tags: ApiTagDescription[] = [ - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: boardId, - categories: IMAGE_CATEGORIES, - }), - }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: boardId, - categories: ASSETS_CATEGORIES, - }), - }, - { - type: 'Board', - id: boardId, - }, - { - type: 'BoardImagesTotal', - id: boardId, - }, - ]; - - return tags; + if (!result) { + return []; } - return []; + // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries + // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags + // will force those queries to re-fetch, and the requests will of course 404. + return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards); }, }), /** * Change an image's `is_intermediate` property. */ - changeImageIsIntermediate: build.mutation({ - query: ({ imageDTO, is_intermediate }) => ({ - url: buildImagesUrl(`i/${imageDTO.image_name}`), + changeImageIsIntermediate: build.mutation< + paths['/api/v1/images/i/{image_name}']['patch']['responses']['200']['content']['application/json'], + { image_name: string; is_intermediate: boolean } + >({ + query: ({ image_name, is_intermediate }) => ({ + url: buildImagesUrl(`i/${image_name}`), method: 'PATCH', body: { is_intermediate }, }), - invalidatesTags: (result, error, { imageDTO }) => { - const categories = getCategories(imageDTO); - const boardId = imageDTO.board_id ?? 'none'; - + invalidatesTags: (result) => { + if (!result) { + return []; + } return [ - { type: 'Image', id: imageDTO.image_name }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: boardId, - categories, - }), - }, - { - type: 'Board', - id: boardId, - }, - { - type: 'BoardImagesTotal', - id: boardId, - }, + ...getTagsToInvalidateForImageMutation([result.image_name]), + ...getTagsToInvalidateForBoardAffectingMutation([result.board_id ?? 'none']), ]; }, }), @@ -236,38 +169,22 @@ export const imagesApi = api.injectEndpoints({ * Star a list of images. */ starImages: build.mutation< - paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'], - { imageDTOs: ImageDTO[] } + paths['/api/v1/images/star']['post']['responses']['200']['content']['application/json'], + paths['/api/v1/images/star']['post']['requestBody']['content']['application/json'] >({ - query: ({ imageDTOs: images }) => ({ + query: (body) => ({ url: buildImagesUrl('star'), method: 'POST', - body: { image_names: images.map((img) => img.image_name) }, + body, }), - invalidatesTags: (result, error, { imageDTOs }) => { - // assume all images are on the same board/category - if (imageDTOs[0]) { - const categories = getCategories(imageDTOs[0]); - const boardId = imageDTOs[0].board_id ?? 'none'; - const tags: ApiTagDescription[] = [ - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: boardId, - categories, - }), - }, - { - type: 'Board', - id: boardId, - }, - ]; - for (const imageDTO of imageDTOs) { - tags.push({ type: 'Image', id: imageDTO.image_name }); - } - return tags; + invalidatesTags: (result) => { + if (!result) { + return []; } - return []; + return [ + ...getTagsToInvalidateForImageMutation(result.starred_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + ]; }, }), /** @@ -275,40 +192,27 @@ export const imagesApi = api.injectEndpoints({ */ unstarImages: build.mutation< paths['/api/v1/images/unstar']['post']['responses']['200']['content']['application/json'], - { imageDTOs: ImageDTO[] } + paths['/api/v1/images/unstar']['post']['requestBody']['content']['application/json'] >({ - query: ({ imageDTOs: images }) => ({ + query: (body) => ({ url: buildImagesUrl('unstar'), method: 'POST', - body: { image_names: images.map((img) => img.image_name) }, + body, }), - invalidatesTags: (result, error, { imageDTOs }) => { - // assume all images are on the same board/category - if (imageDTOs[0]) { - const categories = getCategories(imageDTOs[0]); - const boardId = imageDTOs[0].board_id ?? 'none'; - const tags: ApiTagDescription[] = [ - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: boardId, - categories, - }), - }, - { - type: 'Board', - id: boardId, - }, - ]; - for (const imageDTO of imageDTOs) { - tags.push({ type: 'Image', id: imageDTO.image_name }); - } - return tags; + invalidatesTags: (result) => { + if (!result) { + return []; } - return []; + return [ + ...getTagsToInvalidateForImageMutation(result.unstarred_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + ]; }, }), - uploadImage: build.mutation({ + uploadImage: build.mutation< + paths['/api/v1/images/upload']['post']['responses']['201']['content']['application/json'], + UploadImageArg + >({ query: ({ file, image_category, is_intermediate, session_id, board_id, crop_visible, metadata, resize_to }) => { const formData = new FormData(); formData.append('file', file); @@ -366,8 +270,11 @@ export const imagesApi = api.injectEndpoints({ body: { width, height, board_id }, }), }), - deleteBoard: build.mutation({ - query: (board_id) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }), + deleteBoard: build.mutation< + paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'], + paths['/api/v1/boards/{board_id}']['delete']['parameters']['path'] + >({ + query: ({ board_id }) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }), invalidatesTags: () => [ { type: 'Board', id: LIST_TAG }, // invalidate the 'No Board' cache @@ -388,192 +295,95 @@ export const imagesApi = api.injectEndpoints({ ], }), - deleteBoardAndImages: build.mutation({ - query: (board_id) => ({ + deleteBoardAndImages: build.mutation< + paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'], + paths['/api/v1/boards/{board_id}']['delete']['parameters']['path'] + >({ + query: ({ board_id }) => ({ url: buildBoardsUrl(board_id), method: 'DELETE', params: { include_images: true }, }), invalidatesTags: () => [{ type: 'Board', id: LIST_TAG }], }), - addImageToBoard: build.mutation({ - query: ({ board_id, imageDTO }) => { - const { image_name } = imageDTO; + addImageToBoard: build.mutation< + paths['/api/v1/board_images/']['post']['responses']['201']['content']['application/json'], + paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => { return { url: buildBoardImagesUrl(), method: 'POST', - body: { board_id, image_name }, + body, }; }, - invalidatesTags: (result, error, { board_id, imageDTO }) => { + invalidatesTags: (result) => { + if (!result) { + return []; + } return [ - { type: 'Image', id: imageDTO.image_name }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id, - categories: getCategories(imageDTO), - }), - }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: imageDTO.board_id ?? 'none', - categories: getCategories(imageDTO), - }), - }, - { type: 'Board', id: board_id }, - { type: 'Board', id: imageDTO.board_id ?? 'none' }, - { - type: 'BoardImagesTotal', - id: imageDTO.board_id ?? 'none', - }, - { - type: 'BoardImagesTotal', - id: board_id, - }, + ...getTagsToInvalidateForImageMutation(result.added_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), ]; }, }), - removeImageFromBoard: build.mutation({ - query: ({ imageDTO }) => { - const { image_name } = imageDTO; + removeImageFromBoard: build.mutation< + paths['/api/v1/board_images/']['delete']['responses']['201']['content']['application/json'], + paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json'] + >({ + query: (body) => { return { url: buildBoardImagesUrl(), method: 'DELETE', - body: { image_name }, + body, }; }, - invalidatesTags: (result, error, { imageDTO }) => { + invalidatesTags: (result) => { + if (!result) { + return []; + } return [ - { type: 'Image', id: imageDTO.image_name }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: imageDTO.board_id, - categories: getCategories(imageDTO), - }), - }, - { - type: 'ImageList', - id: getListImagesUrl({ - board_id: 'none', - categories: getCategories(imageDTO), - }), - }, - { type: 'Board', id: imageDTO.board_id ?? 'none' }, - { type: 'Board', id: 'none' }, - { - type: 'BoardImagesTotal', - id: imageDTO.board_id ?? 'none', - }, - { type: 'BoardImagesTotal', id: 'none' }, + ...getTagsToInvalidateForImageMutation(result.removed_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), ]; }, }), addImagesToBoard: build.mutation< - components['schemas']['AddImagesToBoardResult'], - { - board_id: string; - imageDTOs: ImageDTO[]; - } + paths['/api/v1/board_images/batch']['post']['responses']['201']['content']['application/json'], + paths['/api/v1/board_images/batch']['post']['requestBody']['content']['application/json'] >({ - query: ({ board_id, imageDTOs }) => ({ + query: (body) => ({ url: buildBoardImagesUrl('batch'), method: 'POST', - body: { - image_names: imageDTOs.map((i) => i.image_name), - board_id, - }, + body, }), - invalidatesTags: (result, error, { board_id, imageDTOs }) => { - const tags: ApiTagDescription[] = []; - if (imageDTOs[0]) { - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id: imageDTOs[0].board_id ?? 'none', - categories: getCategories(imageDTOs[0]), - }), - }); - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id: board_id, - categories: getCategories(imageDTOs[0]), - }), - }); - tags.push({ type: 'Board', id: imageDTOs[0].board_id ?? 'none' }); - tags.push({ - type: 'BoardImagesTotal', - id: imageDTOs[0].board_id ?? 'none', - }); + invalidatesTags: (result) => { + if (!result) { + return []; } - for (const imageDTO of imageDTOs) { - tags.push({ type: 'Image', id: imageDTO.image_name }); - } - tags.push({ type: 'Board', id: board_id }); - tags.push({ - type: 'BoardImagesTotal', - id: board_id ?? 'none', - }); - return tags; + return [ + ...getTagsToInvalidateForImageMutation(result.added_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + ]; }, }), removeImagesFromBoard: build.mutation< - components['schemas']['RemoveImagesFromBoardResult'], - { - imageDTOs: ImageDTO[]; - } + paths['/api/v1/board_images/batch/delete']['post']['responses']['201']['content']['application/json'], + paths['/api/v1/board_images/batch/delete']['post']['requestBody']['content']['application/json'] >({ - query: ({ imageDTOs }) => ({ + query: (body) => ({ url: buildBoardImagesUrl('batch/delete'), method: 'POST', - body: { - image_names: imageDTOs.map((i) => i.image_name), - }, + body, }), - invalidatesTags: (result, error, { imageDTOs }) => { - const touchedBoardIds: string[] = []; - const tags: ApiTagDescription[] = []; - - if (imageDTOs[0]) { - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id: imageDTOs[0].board_id, - categories: getCategories(imageDTOs[0]), - }), - }); - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id: 'none', - categories: getCategories(imageDTOs[0]), - }), - }); - tags.push({ - type: 'BoardImagesTotal', - id: 'none', - }); + invalidatesTags: (result) => { + if (!result) { + return []; } - - result?.removed_image_names.forEach((image_name) => { - const board_id = imageDTOs.find((i) => i.image_name === image_name)?.board_id; - - if (!board_id || touchedBoardIds.includes(board_id)) { - tags.push({ type: 'Board', id: 'none' }); - return; - } - tags.push({ type: 'Image', id: image_name }); - tags.push({ type: 'Board', id: board_id }); - tags.push({ - type: 'BoardImagesTotal', - id: board_id ?? 'none', - }); - }); - - return tags; + return [ + ...getTagsToInvalidateForImageMutation(result.removed_images), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + ]; }, }), bulkDownloadImages: build.mutation< @@ -711,3 +521,63 @@ export const imageDTOToFile = async (imageDTO: ImageDTO): Promise => { const file = new File([blob], `copy_of_${imageDTO.image_name}`, { type: 'image/png' }); return file; }; + +export const useImageDTO = (imageName: string | null | undefined) => { + const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); + return imageDTO ?? null; +}; + +export const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => { + const tags: ApiTagDescription[] = []; + + for (const image_name of image_names) { + tags.push({ + type: 'Image', + id: image_name, + }); + tags.push({ + type: 'ImageMetadata', + id: image_name, + }); + tags.push({ + type: 'ImageWorkflow', + id: image_name, + }); + } + + return tags; +}; + +export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => { + const tags: ApiTagDescription[] = []; + + for (const board_id of affected_boards) { + tags.push({ + type: 'ImageList', + id: getListImagesUrl({ + board_id, + categories: IMAGE_CATEGORIES, + }), + }); + + tags.push({ + type: 'ImageList', + id: getListImagesUrl({ + board_id, + categories: ASSETS_CATEGORIES, + }), + }); + + tags.push({ + type: 'Board', + id: board_id, + }); + + tags.push({ + type: 'BoardImagesTotal', + id: board_id, + }); + } + + return tags; +}; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index c40d3fb5a0..da6adb2fb6 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -117,7 +117,7 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi ); } else { // Else just select the image, no need to switch boards - dispatch(imageSelected(lastImageDTO)); + dispatch(imageSelected(lastImageDTO.image_name)); if (galleryView !== 'images') { // We also need to update the gallery view to images. This also updates the offset. From a294e8e0fd856a433eb5d9c8f1525a122eaeb315 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:16:18 +1000 Subject: [PATCH 145/210] chore(ui): lint --- .../middleware/listenerMiddleware/index.ts | 6 -- .../listeners/imagesStarred.ts | 25 ------- .../listeners/imagesUnstarred.ts | 25 ------- .../ImageViewer/CurrentImageButtons.tsx | 2 +- .../ImageViewer/CurrentImagePreview.tsx | 2 +- .../ImageViewer/ImageViewerPanel.tsx | 73 +------------------ .../ToggleMetadataViewerButton.tsx | 2 +- .../components/ImageViewer/context.tsx | 70 ++++++++++++++++++ 8 files changed, 76 insertions(+), 129 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index aaf4c52c7a..856dc31ec7 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -15,8 +15,6 @@ import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMi import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema'; import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard'; import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard'; -import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred'; -import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred'; import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded'; import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected'; import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded'; @@ -47,10 +45,6 @@ addImageUploadedFulfilledListener(startAppListening); // Image deleted addDeleteBoardAndImagesFulfilledListener(startAppListening); -// Image starred -addImagesStarredListener(startAppListening); -addImagesUnstarredListener(startAppListening); - // Gallery addGalleryImageClickedListener(startAppListening); addGalleryOffsetChangedListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts deleted file mode 100644 index f4d014b055..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { imagesApi } from 'services/api/endpoints/images'; - -export const addImagesStarredListener = (startAppListening: AppStartListening) => { - startAppListening({ - matcher: imagesApi.endpoints.starImages.matchFulfilled, - effect: (action, { dispatch, getState }) => { - // const { updated_image_names: starredImages } = action.payload; - // const state = getState(); - // const { selection } = state.gallery; - // const updatedSelection: ImageDTO[] = []; - // selection.forEach((selectedImageDTO) => { - // if (starredImages.includes(selectedImageDTO.image_name)) { - // updatedSelection.push({ - // ...selectedImageDTO, - // starred: true, - // }); - // } else { - // updatedSelection.push(selectedImageDTO); - // } - // }); - // dispatch(selectionChanged(updatedSelection)); - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts deleted file mode 100644 index 00aaf28e91..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { imagesApi } from 'services/api/endpoints/images'; - -export const addImagesUnstarredListener = (startAppListening: AppStartListening) => { - startAppListening({ - matcher: imagesApi.endpoints.unstarImages.matchFulfilled, - effect: (action, { dispatch, getState }) => { - // const { updated_image_names: unstarredImages } = action.payload; - // const state = getState(); - // const { selection } = state.gallery; - // const updatedSelection: ImageDTO[] = []; - // selection.forEach((selectedImageDTO) => { - // if (unstarredImages.includes(selectedImageDTO.image_name)) { - // updatedSelection.push({ - // ...selectedImageDTO, - // starred: false, - // }); - // } else { - // updatedSelection.push(selectedImageDTO); - // } - // }); - // dispatch(selectionChanged(updatedSelection)); - }, - }); -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index ee2fe1e884..987b6cd911 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -23,7 +23,7 @@ import { } from 'react-icons/pi'; import { useImageDTO } from 'services/api/endpoints/images'; -import { useImageViewerContext } from './ImageViewerPanel'; +import { useImageViewerContext } from './context'; export const CurrentImageButtons = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 652ebd9381..faa0bd91ad 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -18,7 +18,7 @@ import { NoContentForViewer } from './NoContentForViewer'; import { ProgressImage } from './ProgressImage2'; import { ProgressIndicator } from './ProgressIndicator2'; -export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { +export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => { const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx index bc7cda66b2..b15e19c9e1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx @@ -1,14 +1,9 @@ import { Divider, Flex } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar'; -import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; -import { type Atom, atom, computed } from 'nanostores'; -import type { PropsWithChildren } from 'react'; -import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import type { ImageDTO, S } from 'services/api/types'; -import { $socket } from 'services/events/stores'; -import { assert } from 'tsafe'; +import { memo } from 'react'; + +import { ImageViewerContextProvider } from './context'; export const ImageViewerPanel = memo(() => { return ( @@ -22,65 +17,3 @@ export const ImageViewerPanel = memo(() => { ); }); ImageViewerPanel.displayName = 'ImageViewerPanel'; - -type ImageViewerContextValue = { - $progressEvent: Atom; - $progressImage: Atom; - $hasProgressImage: Atom; - onLoadImage: (imageDTO: ImageDTO) => void; -}; - -const ImageViewerContext = createContext(null); - -const ImageViewerContextProvider = memo((props: PropsWithChildren) => { - const socket = useStore($socket); - const $progressEvent = useState(() => atom(null))[0]; - const $progressImage = useState(() => atom(null))[0]; - const $hasProgressImage = useState(() => computed($progressImage, (progressImage) => progressImage !== null))[0]; - - useEffect(() => { - if (!socket) { - return; - } - - const onInvocationProgress = (data: S['InvocationProgressEvent']) => { - $progressEvent.set(data); - if (data.image) { - $progressImage.set(data.image); - } - }; - - socket.on('invocation_progress', onInvocationProgress); - - return () => { - socket.off('invocation_progress', onInvocationProgress); - }; - }, [$progressEvent, $progressImage, socket]); - - const onLoadImage = useCallback( - (imageDTO: ImageDTO) => { - const progressEvent = $progressEvent.get(); - if (!progressEvent || !imageDTO) { - return; - } - if (progressEvent.session_id === imageDTO.session_id) { - $progressImage.set(null); - } - }, - [$progressEvent, $progressImage] - ); - - const value = useMemo( - () => ({ $progressEvent, $progressImage, $hasProgressImage, onLoadImage }), - [$hasProgressImage, $progressEvent, $progressImage, onLoadImage] - ); - - return {props.children}; -}); -ImageViewerContextProvider.displayName = 'ImageViewerContextProvider'; - -export const useImageViewerContext = () => { - const value = useContext(ImageViewerContext); - assert(value !== null, 'useImageViewerContext must be used within a ImageViewerContextProvider'); - return value; -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx index 1824ca1353..983895e26e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx @@ -9,7 +9,7 @@ import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiInfoBold } from 'react-icons/pi'; -import { useImageViewerContext } from './ImageViewerPanel'; +import { useImageViewerContext } from './context'; export const ToggleMetadataViewerButton = memo(() => { const dispatch = useAppDispatch(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx new file mode 100644 index 0000000000..f664448bfe --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx @@ -0,0 +1,70 @@ +import { useStore } from '@nanostores/react'; +import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; +import { type Atom, atom, computed } from 'nanostores'; +import type { PropsWithChildren } from 'react'; +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { ImageDTO, S } from 'services/api/types'; +import { $socket } from 'services/events/stores'; +import { assert } from 'tsafe'; + +type ImageViewerContextValue = { + $progressEvent: Atom; + $progressImage: Atom; + $hasProgressImage: Atom; + onLoadImage: (imageDTO: ImageDTO) => void; +}; + +const ImageViewerContext = createContext(null); + +export const ImageViewerContextProvider = memo((props: PropsWithChildren) => { + const socket = useStore($socket); + const $progressEvent = useState(() => atom(null))[0]; + const $progressImage = useState(() => atom(null))[0]; + const $hasProgressImage = useState(() => computed($progressImage, (progressImage) => progressImage !== null))[0]; + + useEffect(() => { + if (!socket) { + return; + } + + const onInvocationProgress = (data: S['InvocationProgressEvent']) => { + $progressEvent.set(data); + if (data.image) { + $progressImage.set(data.image); + } + }; + + socket.on('invocation_progress', onInvocationProgress); + + return () => { + socket.off('invocation_progress', onInvocationProgress); + }; + }, [$progressEvent, $progressImage, socket]); + + const onLoadImage = useCallback( + (imageDTO: ImageDTO) => { + const progressEvent = $progressEvent.get(); + if (!progressEvent || !imageDTO) { + return; + } + if (progressEvent.session_id === imageDTO.session_id) { + $progressImage.set(null); + } + }, + [$progressEvent, $progressImage] + ); + + const value = useMemo( + () => ({ $progressEvent, $progressImage, $hasProgressImage, onLoadImage }), + [$hasProgressImage, $progressEvent, $progressImage, onLoadImage] + ); + + return {props.children}; +}); +ImageViewerContextProvider.displayName = 'ImageViewerContextProvider'; + +export const useImageViewerContext = () => { + const value = useContext(ImageViewerContext); + assert(value !== null, 'useImageViewerContext must be used within a ImageViewerContextProvider'); + return value; +}; From ac81ec41c3aaee057493495f07622614c68fa3a6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:16:38 +1000 Subject: [PATCH 146/210] chore: bump version to v6.0.0a5 --- 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 6656473abc..ebdccfbad6 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "6.0.0a4" +__version__ = "6.0.0a5" From 049a8d81447264c96a2be0e7bc05ff1b62d20cfc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 06:07:17 +1000 Subject: [PATCH 147/210] fix(ui): fix metadata toggle stuck disabled --- .../web/src/features/gallery/components/ImageViewer/context.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx index f664448bfe..babbab38c3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx @@ -48,6 +48,7 @@ export const ImageViewerContextProvider = memo((props: PropsWithChildren) => { return; } if (progressEvent.session_id === imageDTO.session_id) { + $progressEvent.set(null); $progressImage.set(null); } }, From bee4cf41b45ac310117ed2a28d42467da191b075 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 15:51:28 +1000 Subject: [PATCH 148/210] refactor: gallery scroll --- invokeai/app/api/routers/images.py | 62 +++- .../image_records/image_records_base.py | 30 +- .../image_records/image_records_sqlite.py | 180 +++++++++- invokeai/app/services/images/images_base.py | 30 +- .../app/services/images/images_default.py | 67 +++- invokeai/frontend/web/.eslintrc.js | 4 +- invokeai/frontend/web/src/app/store/store.ts | 3 +- .../features/gallery/components/Gallery.tsx | 8 +- .../gallery/components/NewGallery.tsx | 340 ++++++++++++++++++ .../gallery/store/gallerySelectors.ts | 10 +- .../web/src/services/api/endpoints/images.ts | 78 +++- .../frontend/web/src/services/api/index.ts | 2 + .../frontend/web/src/services/api/schema.ts | 131 +++++++ 13 files changed, 928 insertions(+), 17 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index a2ac6b45c8..d224242a56 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -1,7 +1,7 @@ import io import json import traceback -from typing import ClassVar, Optional +from typing import ClassVar, Literal, Optional from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile from fastapi.responses import FileResponse @@ -562,3 +562,63 @@ async def get_bulk_download_item( return response except Exception: raise HTTPException(status_code=404) + + +@images_router.get("/collections/counts", operation_id="get_image_collection_counts") +async def get_image_collection_counts( + image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to count."), + categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), + is_intermediate: Optional[bool] = Query(default=None, description="Whether to include intermediate images."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find images without a board.", + ), + search_term: Optional[str] = Query(default=None, description="The term to search for"), +) -> dict[str, int]: + """Gets counts for starred and unstarred image collections""" + + try: + counts = ApiDependencies.invoker.services.images.get_collection_counts( + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + ) + return counts + except Exception: + raise HTTPException(status_code=500, detail="Failed to get collection counts") + + +@images_router.get("/collections/{collection}", operation_id="get_image_collection") +async def get_image_collection( + collection: Literal["starred", "unstarred"] = Path(..., description="The collection to retrieve from"), + image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), + categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), + is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find images without a board.", + ), + offset: int = Query(default=0, description="The offset within the collection"), + limit: int = Query(default=50, description="The number of images to return"), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + search_term: Optional[str] = Query(default=None, description="The term to search for"), +) -> OffsetPaginatedResults[ImageDTO]: + """Gets images from a specific collection (starred or unstarred)""" + + try: + image_dtos = ApiDependencies.invoker.services.images.get_collection_images( + collection=collection, + offset=offset, + limit=limit, + order_dir=order_dir, + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + ) + return image_dtos + except Exception: + raise HTTPException(status_code=500, detail="Failed to get collection images") diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 1211c9762c..de42fa419d 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from datetime import datetime -from typing import Optional +from typing import Literal, Optional from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( @@ -97,3 +97,31 @@ class ImageRecordStorageBase(ABC): def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]: """Gets the most recent image for a board.""" pass + + @abstractmethod + def get_collection_counts( + self, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> dict[str, int]: + """Gets counts for starred and unstarred image collections.""" + pass + + @abstractmethod + def get_collection_images( + self, + collection: Literal["starred", "unstarred"], + offset: int = 0, + limit: int = 10, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> OffsetPaginatedResults[ImageRecord]: + """Gets images from a specific collection (starred or unstarred).""" + pass diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index 23674e14e6..592004b793 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -1,6 +1,6 @@ import sqlite3 from datetime import datetime -from typing import Optional, Union, cast +from typing import Literal, Optional, Union, cast from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase @@ -386,3 +386,181 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): return None return deserialize_image_record(dict(result)) + + def get_collection_counts( + self, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> dict[str, int]: + cursor = self._conn.cursor() + + # Build the base query conditions (same as get_many) + base_query = """--sql + FROM images + LEFT JOIN board_images ON board_images.image_name = images.image_name + WHERE 1=1 + """ + + query_conditions = "" + query_params: list[Union[int, str, bool]] = [] + + if image_origin is not None: + query_conditions += """--sql + AND images.image_origin = ? + """ + query_params.append(image_origin.value) + + if categories is not None: + category_strings = [c.value for c in set(categories)] + placeholders = ",".join("?" * len(category_strings)) + query_conditions += f"""--sql + AND images.image_category IN ( {placeholders} ) + """ + for c in category_strings: + query_params.append(c) + + if is_intermediate is not None: + query_conditions += """--sql + AND images.is_intermediate = ? + """ + query_params.append(is_intermediate) + + if board_id == "none": + query_conditions += """--sql + AND board_images.board_id IS NULL + """ + elif board_id is not None: + query_conditions += """--sql + AND board_images.board_id = ? + """ + query_params.append(board_id) + + if search_term: + query_conditions += """--sql + AND ( + images.metadata LIKE ? + OR images.created_at LIKE ? + ) + """ + query_params.append(f"%{search_term.lower()}%") + query_params.append(f"%{search_term.lower()}%") + + # Get starred count + starred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = TRUE;" + cursor.execute(starred_query, query_params) + starred_count = cast(int, cursor.fetchone()[0]) + + # Get unstarred count + unstarred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = FALSE;" + cursor.execute(unstarred_query, query_params) + unstarred_count = cast(int, cursor.fetchone()[0]) + + return { + "starred_count": starred_count, + "unstarred_count": unstarred_count, + "total_count": starred_count + unstarred_count, + } + + def get_collection_images( + self, + collection: Literal["starred", "unstarred"], + offset: int = 0, + limit: int = 10, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> OffsetPaginatedResults[ImageRecord]: + cursor = self._conn.cursor() + + # Base queries + count_query = """--sql + SELECT COUNT(*) + FROM images + LEFT JOIN board_images ON board_images.image_name = images.image_name + WHERE 1=1 + """ + + images_query = f"""--sql + SELECT {IMAGE_DTO_COLS} + FROM images + LEFT JOIN board_images ON board_images.image_name = images.image_name + WHERE 1=1 + """ + + query_conditions = "" + query_params: list[Union[int, str, bool]] = [] + + # Add starred/unstarred filter + is_starred = collection == "starred" + query_conditions += """--sql + AND images.starred = ? + """ + query_params.append(is_starred) + + if image_origin is not None: + query_conditions += """--sql + AND images.image_origin = ? + """ + query_params.append(image_origin.value) + + if categories is not None: + category_strings = [c.value for c in set(categories)] + placeholders = ",".join("?" * len(category_strings)) + query_conditions += f"""--sql + AND images.image_category IN ( {placeholders} ) + """ + for c in category_strings: + query_params.append(c) + + if is_intermediate is not None: + query_conditions += """--sql + AND images.is_intermediate = ? + """ + query_params.append(is_intermediate) + + if board_id == "none": + query_conditions += """--sql + AND board_images.board_id IS NULL + """ + elif board_id is not None: + query_conditions += """--sql + AND board_images.board_id = ? + """ + query_params.append(board_id) + + if search_term: + query_conditions += """--sql + AND ( + images.metadata LIKE ? + OR images.created_at LIKE ? + ) + """ + query_params.append(f"%{search_term.lower()}%") + query_params.append(f"%{search_term.lower()}%") + + # Add ordering and pagination + query_pagination = f"""--sql + ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ? + """ + + # Execute images query + images_query += query_conditions + query_pagination + ";" + images_params = query_params.copy() + images_params.extend([limit, offset]) + + cursor.execute(images_query, images_params) + result = cast(list[sqlite3.Row], cursor.fetchall()) + images = [deserialize_image_record(dict(r)) for r in result] + + # Execute count query + count_query += query_conditions + ";" + cursor.execute(count_query, query_params) + count = cast(int, cursor.fetchone()[0]) + + return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count) diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index 5328c1854e..dd998e2578 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Callable, Optional +from typing import Callable, Literal, Optional from PIL.Image import Image as PILImageType @@ -147,3 +147,31 @@ class ImageServiceABC(ABC): def delete_images_on_board(self, board_id: str): """Deletes all images on a board.""" pass + + @abstractmethod + def get_collection_counts( + self, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> dict[str, int]: + """Gets counts for starred and unstarred image collections.""" + pass + + @abstractmethod + def get_collection_images( + self, + collection: Literal["starred", "unstarred"], + offset: int = 0, + limit: int = 10, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> OffsetPaginatedResults[ImageDTO]: + """Gets images from a specific collection (starred or unstarred).""" + pass diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 1489a7ce45..83809ad3f4 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Literal, Optional from PIL.Image import Image as PILImageType @@ -309,3 +309,68 @@ class ImageService(ImageServiceABC): except Exception as e: self.__invoker.services.logger.error("Problem getting intermediates count") raise e + + def get_collection_counts( + self, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> dict[str, int]: + try: + return self.__invoker.services.image_records.get_collection_counts( + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + ) + except Exception as e: + self.__invoker.services.logger.error("Problem getting collection counts") + raise e + + def get_collection_images( + self, + collection: Literal["starred", "unstarred"], + offset: int = 0, + limit: int = 10, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> OffsetPaginatedResults[ImageDTO]: + try: + results = self.__invoker.services.image_records.get_collection_images( + collection=collection, + offset=offset, + limit=limit, + order_dir=order_dir, + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + ) + + image_dtos = [ + image_record_to_dto( + image_record=r, + image_url=self.__invoker.services.urls.get_image_url(r.image_name), + thumbnail_url=self.__invoker.services.urls.get_image_url(r.image_name, True), + board_id=self.__invoker.services.board_image_records.get_board_for_image(r.image_name), + ) + for r in results.items + ] + + return OffsetPaginatedResults[ImageDTO]( + items=image_dtos, + offset=results.offset, + limit=results.limit, + total=results.total, + ) + except Exception as e: + self.__invoker.services.logger.error("Problem getting collection images") + raise e diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js index 3e6498af4c..f4af658ea2 100644 --- a/invokeai/frontend/web/.eslintrc.js +++ b/invokeai/frontend/web/.eslintrc.js @@ -12,11 +12,13 @@ module.exports = { // TODO: ENABLE THIS RULE BEFORE v6.0.0 // 'i18next/no-literal-string': 'error', // https://eslint.org/docs/latest/rules/no-console - 'no-console': 'error', + 'no-console': 'warn', // https://eslint.org/docs/latest/rules/no-promise-executor-return 'no-promise-executor-return': 'error', // https://eslint.org/docs/latest/rules/require-await 'require-await': 'error', + // TODO: ENABLE THIS RULE BEFORE v6.0.0 + 'react/display-name': 'off', 'no-restricted-properties': [ 'error', { diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 9397144751..ec757494f5 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -39,7 +39,6 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware'; import type { JsonObject } from 'type-fest'; import { STORAGE_PREFIX } from './constants'; -import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware'; import { actionSanitizer } from './middleware/devtools/actionSanitizer'; import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { stateSanitizer } from './middleware/devtools/stateSanitizer'; @@ -177,7 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) - .concat(getDebugLoggerMiddleware()) + // .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 1679cc1fb1..ec8f15f72e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -25,9 +25,8 @@ import { useBoardName } from 'services/api/hooks/useBoardName'; import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover'; import { GalleryUploadButton } from './GalleryUploadButton'; -import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; -import { GalleryPagination } from './ImageGrid/GalleryPagination'; import { GallerySearch } from './ImageGrid/GallerySearch'; +import { NewGallery } from './NewGallery'; const BASE_STYLES: ChakraProps['sx'] = { fontWeight: 'semibold', @@ -112,8 +111,9 @@ export const GalleryPanel = memo(() => { /> - - + {/* + */} + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx new file mode 100644 index 0000000000..9f6807d278 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -0,0 +1,340 @@ +import { Box, Flex, forwardRef, Grid, GridItem, Image, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors'; +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { VirtuosoGrid } from 'react-virtuoso'; +import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +// Placeholder image component for now +const ImagePlaceholder = memo(({ image }: { image: ImageDTO }) => ( + +)); + +ImagePlaceholder.displayName = 'ImagePlaceholder'; + +// Loading skeleton component +const ImageSkeleton = memo(() => ); + +ImageSkeleton.displayName = 'ImageSkeleton'; + +// Hook to manage image data for virtual scrolling +const useVirtualImageData = () => { + const queryArgs = useAppSelector(selectImageCollectionQueryArgs); + + // Get total counts for position mapping + const { data: counts, isLoading: countsLoading } = useGetImageCollectionCountsQuery(queryArgs); + + // Cache for loaded image ranges + const [loadedRanges, setLoadedRanges] = useState>(new Map()); + + // Calculate position mappings + const positionInfo = useMemo(() => { + if (!counts) { + return null; + } + + const result = { + totalCount: counts.total_count, + starredCount: counts.starred_count ?? 0, + unstarredCount: counts.unstarred_count ?? 0, + starredEnd: (counts.starred_count ?? 0) - 1, + }; + + return result; + }, [counts]); + + // Clear cache when search parameters change + React.useEffect(() => { + setLoadedRanges(new Map()); + }, [queryArgs.board_id, queryArgs.search_term, queryArgs.categories]); + + // Return flag to indicate when search parameters have changed + const searchParamsChanged = useMemo(() => queryArgs, [queryArgs]); + + // Function to generate cache key for a range + const getRangeKey = useCallback((collection: 'starred' | 'unstarred', offset: number, limit: number) => { + return `${collection}-${offset}-${limit}`; + }, []); + + // Function to get images for a specific position range + const getImagesForRange = useCallback( + (startIndex: number, endIndex: number) => { + if (!positionInfo) { + return []; + } + + const requestedImages: (ImageDTO | null)[] = new Array(endIndex - startIndex + 1).fill(null); + const rangesToLoad: Array<{ + collection: 'starred' | 'unstarred'; + offset: number; + limit: number; + targetStartIndex: number; + }> = []; + + for (let i = startIndex; i <= endIndex; i++) { + const relativeIndex = i - startIndex; + + // Handle case where there are no starred images + if (positionInfo.starredCount === 0 || i >= positionInfo.starredCount) { + // This position is in the unstarred collection + const unstarredOffset = i - positionInfo.starredCount; + const rangeKey = getRangeKey('unstarred', Math.floor(unstarredOffset / 50) * 50, 50); + const cachedRange = loadedRanges.get(rangeKey); + + if (cachedRange) { + const imageIndex = unstarredOffset % 50; + if (imageIndex < cachedRange.length) { + requestedImages[relativeIndex] = cachedRange[imageIndex] ?? null; + } + } else { + // Need to load this range + const rangeOffset = Math.floor(unstarredOffset / 50) * 50; + rangesToLoad.push({ + collection: 'unstarred', + offset: rangeOffset, + limit: 50, + targetStartIndex: i, + }); + } + } else { + // This position is in the starred collection + const starredOffset = i; + const rangeKey = getRangeKey('starred', Math.floor(starredOffset / 50) * 50, 50); + const cachedRange = loadedRanges.get(rangeKey); + + if (cachedRange) { + const imageIndex = starredOffset % 50; + if (imageIndex < cachedRange.length) { + requestedImages[relativeIndex] = cachedRange[imageIndex] ?? null; + } + } else { + // Need to load this range + const rangeOffset = Math.floor(starredOffset / 50) * 50; + rangesToLoad.push({ + collection: 'starred', + offset: rangeOffset, + limit: 50, + targetStartIndex: i, + }); + } + } + } + + return { images: requestedImages, rangesToLoad }; + }, + [positionInfo, loadedRanges, getRangeKey] + ); + + return { + positionInfo, + countsLoading, + getImagesForRange, + setLoadedRanges, + loadedRanges, + searchParamsChanged, + }; +}; + +// Component to handle loading image ranges +const ImageRangeLoader = memo( + ({ + collection, + offset, + limit, + onDataLoaded, + }: { + collection: 'starred' | 'unstarred'; + offset: number; + limit: number; + onDataLoaded: (key: string, images: ImageDTO[]) => void; + }) => { + const queryArgs = useAppSelector(selectImageCollectionQueryArgs); + + const { data } = useGetImageCollectionQuery({ + collection, + offset, + limit, + ...queryArgs, + }); + + // Update cache when data is loaded - use useEffect to avoid state update during render + React.useEffect(() => { + if (data?.items) { + const key = `${collection}-${offset}-${limit}`; + onDataLoaded(key, data.items); + } + }, [data, collection, offset, limit, onDataLoaded]); + + return null; + } +); + +ImageRangeLoader.displayName = 'ImageRangeLoader'; + +export const NewGallery = memo(() => { + const { positionInfo, countsLoading, getImagesForRange, setLoadedRanges, searchParamsChanged } = + useVirtualImageData(); + const [activeRangeLoaders, setActiveRangeLoaders] = useState>(new Set()); + + // Force initial range loading when position info becomes available + const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); + + // Reset hasInitiallyLoaded when search parameters change + React.useEffect(() => { + setHasInitiallyLoaded(false); + setActiveRangeLoaders(new Set()); + }, [searchParamsChanged]); + + // Use useEffect for initial load to avoid state updates during render + React.useEffect(() => { + if (positionInfo && !hasInitiallyLoaded) { + // Force initial load of first 100 positions to ensure we see both starred and unstarred + const initialResult = getImagesForRange(0, Math.min(99, positionInfo.totalCount - 1)); + if (!Array.isArray(initialResult)) { + const { rangesToLoad } = initialResult; + rangesToLoad.forEach((rangeInfo) => { + const key = `${rangeInfo.collection}-${rangeInfo.offset}-${rangeInfo.limit}`; + if (!activeRangeLoaders.has(key)) { + setActiveRangeLoaders((prev) => new Set(prev).add(key)); + } + }); + } + setHasInitiallyLoaded(true); + } + }, [positionInfo, hasInitiallyLoaded, getImagesForRange, activeRangeLoaders]); + + // Handle range changes from virtuoso + const handleRangeChanged = useCallback( + (range: { startIndex: number; endIndex: number }) => { + if (!positionInfo) { + return; + } + + const result = getImagesForRange(range.startIndex, range.endIndex); + if (!Array.isArray(result)) { + const { rangesToLoad } = result; + + // Start loading any missing ranges + rangesToLoad.forEach((rangeInfo) => { + const key = `${rangeInfo.collection}-${rangeInfo.offset}-${rangeInfo.limit}`; + if (!activeRangeLoaders.has(key)) { + setActiveRangeLoaders((prev) => new Set(prev).add(key)); + } + }); + } + }, + [positionInfo, getImagesForRange, activeRangeLoaders] + ); + + // Handle when range data is loaded + const handleDataLoaded = useCallback( + (key: string, images: ImageDTO[]) => { + setLoadedRanges((prev) => new Map(prev).set(key, images)); + setActiveRangeLoaders((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, + [setLoadedRanges] + ); + + const computeItemKey = useCallback( + (index: number) => { + const result = getImagesForRange(index, index); + if (Array.isArray(result)) { + return `loading-${index}`; + } + const { images } = result; + const image = images[0]; + return image ? `image-${index}-${image.image_name}` : `skeleton-${index}`; + }, + [getImagesForRange] + ); + + // Render item at specific index + const itemContent = useCallback( + (index: number) => { + if (!positionInfo) { + return ; + } + + const result = getImagesForRange(index, index); + if (Array.isArray(result)) { + return ; + } + + const { images } = result; + const image = images[0]; + + if (image) { + return ; + } + + return ; + }, + [positionInfo, getImagesForRange] + ); + + if (countsLoading) { + return ( + + + Loading gallery... + + ); + } + + if (!positionInfo || positionInfo.totalCount === 0) { + return ( + + No images found + + ); + } + + return ( + + {/* Render active range loaders */} + {Array.from(activeRangeLoaders).map((key) => { + const [collection, offset, limit] = key.split('-'); + return ( + + ); + })} + + {/* Virtualized grid */} + + + ); +}); + +NewGallery.displayName = 'NewGallery'; + +const style = { height: '100%', width: '100%' }; + +const ListComponent = forwardRef((props, ref) => ( + +)); + +const ItemComponent = forwardRef((props, ref) => ); + +const components = { + Item: ItemComponent, + List: ListComponent, +}; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index a3f10d47e9..3e132a2d72 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -4,7 +4,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types'; +import type { ListBoardsArgs, ListImagesArgs, SQLiteDirection } from 'services/api/types'; export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); @@ -38,6 +38,14 @@ export const selectListBoardsQueryArgs = createMemoizedSelector( export const selectAutoAddBoardId = createSelector(selectGallerySlice, (gallery) => gallery.autoAddBoardId); export const selectSelectedBoardId = createSelector(selectGallerySlice, (gallery) => gallery.selectedBoardId); + +export const selectImageCollectionQueryArgs = createMemoizedSelector(selectGallerySlice, (gallery) => ({ + board_id: gallery.selectedBoardId === 'none' ? undefined : gallery.selectedBoardId, + categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES, + search_term: gallery.searchTerm || undefined, + order_dir: gallery.orderDir as SQLiteDirection, + is_intermediate: false, +})); export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, (gallery) => gallery.autoAssignBoardOnClick diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 0938d4a2a9..f3d867b7d3 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -77,7 +77,12 @@ export const imagesApi = api.injectEndpoints({ }), clearIntermediates: build.mutation({ query: () => ({ url: buildImagesUrl('intermediates'), method: 'DELETE' }), - invalidatesTags: ['IntermediatesCount', 'InvocationCacheStatus'], + invalidatesTags: [ + 'IntermediatesCount', + 'InvocationCacheStatus', + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, + ], }), getImageDTO: build.query({ query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}`) }), @@ -106,7 +111,11 @@ export const imagesApi = api.injectEndpoints({ // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags // will force those queries to re-fetch, and the requests will of course 404. - return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards); + return [ + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, + ]; }, }), deleteImages: build.mutation< @@ -125,7 +134,11 @@ export const imagesApi = api.injectEndpoints({ // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags // will force those queries to re-fetch, and the requests will of course 404. - return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards); + return [ + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, + ]; }, }), deleteUncategorizedImages: build.mutation< @@ -140,7 +153,11 @@ export const imagesApi = api.injectEndpoints({ // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags // will force those queries to re-fetch, and the requests will of course 404. - return getTagsToInvalidateForBoardAffectingMutation(result.affected_boards); + return [ + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, + ]; }, }), /** @@ -184,6 +201,8 @@ export const imagesApi = api.injectEndpoints({ return [ ...getTagsToInvalidateForImageMutation(result.starred_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, ]; }, }), @@ -206,6 +225,8 @@ export const imagesApi = api.injectEndpoints({ return [ ...getTagsToInvalidateForImageMutation(result.unstarred_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, ]; }, }), @@ -399,6 +420,52 @@ export const imagesApi = api.injectEndpoints({ }, }), }), + /** + * Get counts for starred and unstarred image collections + */ + getImageCollectionCounts: build.query< + paths['/api/v1/images/collections/counts']['get']['responses']['200']['content']['application/json'], + paths['/api/v1/images/collections/counts']['get']['parameters']['query'] + >({ + query: (queryArgs) => ({ + url: buildImagesUrl('collections/counts'), + method: 'GET', + params: queryArgs, + }), + providesTags: ['ImageCollectionCounts', 'FetchOnReconnect'], + }), + /** + * Get images from a specific collection (starred or unstarred) + */ + getImageCollection: build.query< + paths['/api/v1/images/collections/{collection}']['get']['responses']['200']['content']['application/json'], + paths['/api/v1/images/collections/{collection}']['get']['parameters']['path'] & + paths['/api/v1/images/collections/{collection}']['get']['parameters']['query'] + >({ + query: ({ collection, ...queryArgs }) => ({ + url: buildImagesUrl(`collections/${collection}`), + method: 'GET', + params: queryArgs, + }), + providesTags: (result, error, { collection, board_id, categories }) => { + const cacheKey = `${collection}-${board_id || 'all'}-${categories?.join(',') || 'all'}`; + return [{ type: 'ImageCollection', id: cacheKey }, 'FetchOnReconnect']; + }, + async onQueryStarted(_, { dispatch, queryFulfilled }) { + // Populate the getImageDTO cache with these images, similar to listImages + const res = await queryFulfilled; + const imageDTOs = res.data.items; + const updates: Param0 = []; + for (const imageDTO of imageDTOs) { + updates.push({ + endpointName: 'getImageDTO', + arg: imageDTO.image_name, + value: imageDTO, + }); + } + dispatch(imagesApi.util.upsertQueryEntries(updates)); + }, + }), }), }); @@ -420,6 +487,9 @@ export const { useStarImagesMutation, useUnstarImagesMutation, useBulkDownloadImagesMutation, + useGetImageCollectionCountsQuery, + useGetImageCollectionQuery, + useLazyGetImageCollectionQuery, } = imagesApi; /** diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 123245fdfe..b2b65b4d99 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -23,6 +23,8 @@ const tagTypes = [ 'ImageList', 'ImageMetadata', 'ImageWorkflow', + 'ImageCollectionCounts', + 'ImageCollection', 'ImageMetadataFromFile', 'IntermediatesCount', 'SessionQueueItem', diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 827bc71e9e..a33e90312c 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -752,6 +752,46 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/images/collections/counts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Image Collection Counts + * @description Gets counts for starred and unstarred image collections + */ + get: operations["get_image_collection_counts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/images/collections/{collection}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Image Collection + * @description Gets images from a specific collection (starred or unstarred) + */ + get: operations["get_image_collection"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/boards/": { parameters: { query?: never; @@ -23675,6 +23715,97 @@ export interface operations { }; }; }; + get_image_collection_counts: { + parameters: { + query?: { + /** @description The origin of images to count. */ + image_origin?: components["schemas"]["ResourceOrigin"] | null; + /** @description The categories of image to include. */ + categories?: components["schemas"]["ImageCategory"][] | null; + /** @description Whether to include intermediate images. */ + is_intermediate?: boolean | null; + /** @description The board id to filter by. Use 'none' to find images without a board. */ + board_id?: string | null; + /** @description The term to search for */ + search_term?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: number; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_image_collection: { + parameters: { + query?: { + /** @description The origin of images to list. */ + image_origin?: components["schemas"]["ResourceOrigin"] | null; + /** @description The categories of image to include. */ + categories?: components["schemas"]["ImageCategory"][] | null; + /** @description Whether to list intermediate images. */ + is_intermediate?: boolean | null; + /** @description The board id to filter by. Use 'none' to find images without a board. */ + board_id?: string | null; + /** @description The offset within the collection */ + offset?: number; + /** @description The number of images to return */ + limit?: number; + /** @description The order of sort */ + order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description The term to search for */ + search_term?: string | null; + }; + header?: never; + path: { + /** @description The collection to retrieve from */ + collection: "starred" | "unstarred"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_boards: { parameters: { query?: { From 2c8ce6f2f40a59921d96de9cf99ac0b04772b65d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:03:29 +1000 Subject: [PATCH 149/210] refactor: gallery scroll (improved impl) --- .../gallery/components/NewGallery.tsx | 297 ++++-------------- 1 file changed, 66 insertions(+), 231 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 9f6807d278..985a8a6427 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,7 +1,8 @@ import { Box, Flex, forwardRef, Grid, GridItem, Image, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors'; -import React, { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { VirtuosoGrid } from 'react-virtuoso'; import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; @@ -18,264 +19,113 @@ const ImageSkeleton = memo(() => ); ImageSkeleton.displayName = 'ImageSkeleton'; -// Hook to manage image data for virtual scrolling +// Hook to manage position calculations and image access const useVirtualImageData = () => { const queryArgs = useAppSelector(selectImageCollectionQueryArgs); // Get total counts for position mapping const { data: counts, isLoading: countsLoading } = useGetImageCollectionCountsQuery(queryArgs); - // Cache for loaded image ranges - const [loadedRanges, setLoadedRanges] = useState>(new Map()); - // Calculate position mappings const positionInfo = useMemo(() => { if (!counts) { return null; } - const result = { + return { totalCount: counts.total_count, starredCount: counts.starred_count ?? 0, unstarredCount: counts.unstarred_count ?? 0, starredEnd: (counts.starred_count ?? 0) - 1, }; - - return result; }, [counts]); - // Clear cache when search parameters change - React.useEffect(() => { - setLoadedRanges(new Map()); - }, [queryArgs.board_id, queryArgs.search_term, queryArgs.categories]); - - // Return flag to indicate when search parameters have changed - const searchParamsChanged = useMemo(() => queryArgs, [queryArgs]); - - // Function to generate cache key for a range - const getRangeKey = useCallback((collection: 'starred' | 'unstarred', offset: number, limit: number) => { - return `${collection}-${offset}-${limit}`; - }, []); - - // Function to get images for a specific position range - const getImagesForRange = useCallback( - (startIndex: number, endIndex: number) => { + // Function to get query params for a specific position + const getQueryParamsForPosition = useCallback( + (index: number) => { if (!positionInfo) { - return []; + return null; } - const requestedImages: (ImageDTO | null)[] = new Array(endIndex - startIndex + 1).fill(null); - const rangesToLoad: Array<{ - collection: 'starred' | 'unstarred'; - offset: number; - limit: number; - targetStartIndex: number; - }> = []; - - for (let i = startIndex; i <= endIndex; i++) { - const relativeIndex = i - startIndex; - - // Handle case where there are no starred images - if (positionInfo.starredCount === 0 || i >= positionInfo.starredCount) { - // This position is in the unstarred collection - const unstarredOffset = i - positionInfo.starredCount; - const rangeKey = getRangeKey('unstarred', Math.floor(unstarredOffset / 50) * 50, 50); - const cachedRange = loadedRanges.get(rangeKey); - - if (cachedRange) { - const imageIndex = unstarredOffset % 50; - if (imageIndex < cachedRange.length) { - requestedImages[relativeIndex] = cachedRange[imageIndex] ?? null; - } - } else { - // Need to load this range - const rangeOffset = Math.floor(unstarredOffset / 50) * 50; - rangesToLoad.push({ - collection: 'unstarred', - offset: rangeOffset, - limit: 50, - targetStartIndex: i, - }); - } - } else { - // This position is in the starred collection - const starredOffset = i; - const rangeKey = getRangeKey('starred', Math.floor(starredOffset / 50) * 50, 50); - const cachedRange = loadedRanges.get(rangeKey); - - if (cachedRange) { - const imageIndex = starredOffset % 50; - if (imageIndex < cachedRange.length) { - requestedImages[relativeIndex] = cachedRange[imageIndex] ?? null; - } - } else { - // Need to load this range - const rangeOffset = Math.floor(starredOffset / 50) * 50; - rangesToLoad.push({ - collection: 'starred', - offset: rangeOffset, - limit: 50, - targetStartIndex: i, - }); - } - } + if (positionInfo.starredCount === 0 || index >= positionInfo.starredCount) { + // This position is in the unstarred collection + const unstarredOffset = index - positionInfo.starredCount; + const rangeOffset = Math.floor(unstarredOffset / 50) * 50; + return { + collection: 'unstarred' as const, + offset: rangeOffset, + limit: 50, + imageIndex: unstarredOffset % 50, + }; + } else { + // This position is in the starred collection + const rangeOffset = Math.floor(index / 50) * 50; + return { + collection: 'starred' as const, + offset: rangeOffset, + limit: 50, + imageIndex: index % 50, + }; } - - return { images: requestedImages, rangesToLoad }; }, - [positionInfo, loadedRanges, getRangeKey] + [positionInfo] ); return { positionInfo, countsLoading, - getImagesForRange, - setLoadedRanges, - loadedRanges, - searchParamsChanged, + getQueryParamsForPosition, + queryArgs, }; }; -// Component to handle loading image ranges -const ImageRangeLoader = memo( - ({ - collection, - offset, - limit, - onDataLoaded, - }: { - collection: 'starred' | 'unstarred'; - offset: number; - limit: number; - onDataLoaded: (key: string, images: ImageDTO[]) => void; - }) => { - const queryArgs = useAppSelector(selectImageCollectionQueryArgs); +// Hook to get image data for a specific position using RTK Query cache +const useImageAtPosition = (index: number) => { + const { getQueryParamsForPosition, queryArgs } = useVirtualImageData(); - const { data } = useGetImageCollectionQuery({ - collection, - offset, - limit, - ...queryArgs, - }); + const queryParams = getQueryParamsForPosition(index); - // Update cache when data is loaded - use useEffect to avoid state update during render - React.useEffect(() => { - if (data?.items) { - const key = `${collection}-${offset}-${limit}`; - onDataLoaded(key, data.items); - } - }, [data, collection, offset, limit, onDataLoaded]); + const { data } = useGetImageCollectionQuery( + queryParams + ? { + collection: queryParams.collection, + offset: queryParams.offset, + limit: queryParams.limit, + ...queryArgs, + } + : skipToken + ); + if (!queryParams || !data?.items) { return null; } -); -ImageRangeLoader.displayName = 'ImageRangeLoader'; + return data.items[queryParams.imageIndex] || null; +}; + +// Component to render a single image at a position +const ImageAtPosition = memo(({ index }: { index: number }) => { + const image = useImageAtPosition(index); + + if (image) { + return ; + } + + return ; +}); + +ImageAtPosition.displayName = 'ImageAtPosition'; export const NewGallery = memo(() => { - const { positionInfo, countsLoading, getImagesForRange, setLoadedRanges, searchParamsChanged } = - useVirtualImageData(); - const [activeRangeLoaders, setActiveRangeLoaders] = useState>(new Set()); - - // Force initial range loading when position info becomes available - const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); - - // Reset hasInitiallyLoaded when search parameters change - React.useEffect(() => { - setHasInitiallyLoaded(false); - setActiveRangeLoaders(new Set()); - }, [searchParamsChanged]); - - // Use useEffect for initial load to avoid state updates during render - React.useEffect(() => { - if (positionInfo && !hasInitiallyLoaded) { - // Force initial load of first 100 positions to ensure we see both starred and unstarred - const initialResult = getImagesForRange(0, Math.min(99, positionInfo.totalCount - 1)); - if (!Array.isArray(initialResult)) { - const { rangesToLoad } = initialResult; - rangesToLoad.forEach((rangeInfo) => { - const key = `${rangeInfo.collection}-${rangeInfo.offset}-${rangeInfo.limit}`; - if (!activeRangeLoaders.has(key)) { - setActiveRangeLoaders((prev) => new Set(prev).add(key)); - } - }); - } - setHasInitiallyLoaded(true); - } - }, [positionInfo, hasInitiallyLoaded, getImagesForRange, activeRangeLoaders]); - - // Handle range changes from virtuoso - const handleRangeChanged = useCallback( - (range: { startIndex: number; endIndex: number }) => { - if (!positionInfo) { - return; - } - - const result = getImagesForRange(range.startIndex, range.endIndex); - if (!Array.isArray(result)) { - const { rangesToLoad } = result; - - // Start loading any missing ranges - rangesToLoad.forEach((rangeInfo) => { - const key = `${rangeInfo.collection}-${rangeInfo.offset}-${rangeInfo.limit}`; - if (!activeRangeLoaders.has(key)) { - setActiveRangeLoaders((prev) => new Set(prev).add(key)); - } - }); - } - }, - [positionInfo, getImagesForRange, activeRangeLoaders] - ); - - // Handle when range data is loaded - const handleDataLoaded = useCallback( - (key: string, images: ImageDTO[]) => { - setLoadedRanges((prev) => new Map(prev).set(key, images)); - setActiveRangeLoaders((prev) => { - const next = new Set(prev); - next.delete(key); - return next; - }); - }, - [setLoadedRanges] - ); - - const computeItemKey = useCallback( - (index: number) => { - const result = getImagesForRange(index, index); - if (Array.isArray(result)) { - return `loading-${index}`; - } - const { images } = result; - const image = images[0]; - return image ? `image-${index}-${image.image_name}` : `skeleton-${index}`; - }, - [getImagesForRange] - ); + const { positionInfo, countsLoading } = useVirtualImageData(); // Render item at specific index - const itemContent = useCallback( - (index: number) => { - if (!positionInfo) { - return ; - } + const itemContent = useCallback((index: number) => { + return ; + }, []); - const result = getImagesForRange(index, index); - if (Array.isArray(result)) { - return ; - } - - const { images } = result; - const image = images[0]; - - if (image) { - return ; - } - - return ; - }, - [positionInfo, getImagesForRange] - ); + // Compute item key using position index - let RTK Query handle the caching + const computeItemKey = useCallback((index: number) => `position-${index}`, []); if (countsLoading) { return ( @@ -296,25 +146,10 @@ export const NewGallery = memo(() => { return ( - {/* Render active range loaders */} - {Array.from(activeRangeLoaders).map((key) => { - const [collection, offset, limit] = key.split('-'); - return ( - - ); - })} - {/* Virtualized grid */} Date: Tue, 24 Jun 2025 17:23:04 +1000 Subject: [PATCH 150/210] refactor: gallery scroll (improved impl) --- .../gallery/components/NewGallery.tsx | 172 +++++++++++++----- .../web/src/services/api/endpoints/images.ts | 37 ++-- .../frontend/web/src/services/api/index.ts | 8 +- 3 files changed, 154 insertions(+), 63 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 985a8a6427..9837db60d0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,15 +1,71 @@ import { Box, Flex, forwardRef, Grid, GridItem, Image, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { memo, useCallback, useMemo } from 'react'; +import { + selectGalleryImageMinimumWidth, + selectImageCollectionQueryArgs, +} from 'features/gallery/store/gallerySelectors'; +import { memo, useCallback } from 'react'; import { VirtuosoGrid } from 'react-virtuoso'; -import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery } from 'services/api/endpoints/images'; +import { + useGetImageCollectionCountsQuery, + useGetImageCollectionQuery, + useLazyGetImageCollectionQuery, +} from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; +// Types for range management +type Collection = 'starred' | 'unstarred'; + +interface RangeKey { + collection: Collection; + offset: number; + limit: number; +} + +interface PositionQuery extends RangeKey { + imageIndex: number; +} + +type PositionInfo = { + totalCount: number; + starredCount: number; + unstarredCount: number; + starredEnd: number; +}; + +// Query options factory functions to prevent recreation on every render +const countsQueryOptions = { + selectFromResult: ({ data, isLoading }) => { + const positionInfo: PositionInfo | null = data + ? { + totalCount: data.total_count ?? 0, + starredCount: data.starred_count ?? 0, + unstarredCount: data.unstarred_count ?? 0, + starredEnd: (data.starred_count ?? 0) - 1, + } + : null; + + return { + positionInfo, + isLoading, + }; + }, +} satisfies Parameters[1]; + +const createImageCollectionQueryOptions = (queryParams: PositionQuery | null) => + ({ + skip: !queryParams, + selectFromResult: (result) => { + return { + imageDTO: (queryParams && result.data?.items?.[queryParams.imageIndex]) || null, + }; + }, + }) satisfies Parameters[1]; + // Placeholder image component for now -const ImagePlaceholder = memo(({ image }: { image: ImageDTO }) => ( - +const ImagePlaceholder = memo(({ imageDTO }: { imageDTO: ImageDTO }) => ( + )); ImagePlaceholder.displayName = 'ImagePlaceholder'; @@ -19,30 +75,18 @@ const ImageSkeleton = memo(() => ); ImageSkeleton.displayName = 'ImageSkeleton'; -// Hook to manage position calculations and image access +// Hook to manage position calculations and range loading const useVirtualImageData = () => { const queryArgs = useAppSelector(selectImageCollectionQueryArgs); - // Get total counts for position mapping - const { data: counts, isLoading: countsLoading } = useGetImageCollectionCountsQuery(queryArgs); + // Get position info derived from counts using selectFromResult + const { positionInfo, isLoading } = useGetImageCollectionCountsQuery(queryArgs, countsQueryOptions); - // Calculate position mappings - const positionInfo = useMemo(() => { - if (!counts) { - return null; - } - - return { - totalCount: counts.total_count, - starredCount: counts.starred_count ?? 0, - unstarredCount: counts.unstarred_count ?? 0, - starredEnd: (counts.starred_count ?? 0) - 1, - }; - }, [counts]); + const [triggerGetImageCollection] = useLazyGetImageCollectionQuery(); // Function to get query params for a specific position const getQueryParamsForPosition = useCallback( - (index: number) => { + (index: number): PositionQuery | null => { if (!positionInfo) { return null; } @@ -52,7 +96,7 @@ const useVirtualImageData = () => { const unstarredOffset = index - positionInfo.starredCount; const rangeOffset = Math.floor(unstarredOffset / 50) * 50; return { - collection: 'unstarred' as const, + collection: 'unstarred', offset: rangeOffset, limit: 50, imageIndex: unstarredOffset % 50, @@ -61,7 +105,7 @@ const useVirtualImageData = () => { // This position is in the starred collection const rangeOffset = Math.floor(index / 50) * 50; return { - collection: 'starred' as const, + collection: 'starred', offset: rangeOffset, limit: 50, imageIndex: index % 50, @@ -71,21 +115,48 @@ const useVirtualImageData = () => { [positionInfo] ); + // Function to calculate required ranges for a viewport and trigger lazy queries + const updateRequiredRanges = useCallback( + (startIndex: number, endIndex: number) => { + if (!positionInfo) { + return; + } + + for (let i = startIndex; i <= endIndex; i++) { + const queryParams = getQueryParamsForPosition(i); + if (queryParams) { + const { collection, offset, limit } = queryParams; + triggerGetImageCollection( + { + collection, + offset, + limit, + ...queryArgs, + }, + true + ); + } + } + }, + [positionInfo, getQueryParamsForPosition, triggerGetImageCollection, queryArgs] + ); + return { positionInfo, - countsLoading, + isLoading, getQueryParamsForPosition, queryArgs, + updateRequiredRanges, }; }; -// Hook to get image data for a specific position using RTK Query cache +// Hook to get image data for a specific position using selectFromResult const useImageAtPosition = (index: number) => { const { getQueryParamsForPosition, queryArgs } = useVirtualImageData(); const queryParams = getQueryParamsForPosition(index); - const { data } = useGetImageCollectionQuery( + const { imageDTO } = useGetImageCollectionQuery( queryParams ? { collection: queryParams.collection, @@ -93,22 +164,19 @@ const useImageAtPosition = (index: number) => { limit: queryParams.limit, ...queryArgs, } - : skipToken + : skipToken, + createImageCollectionQueryOptions(queryParams) ); - if (!queryParams || !data?.items) { - return null; - } - - return data.items[queryParams.imageIndex] || null; + return imageDTO; }; // Component to render a single image at a position const ImageAtPosition = memo(({ index }: { index: number }) => { - const image = useImageAtPosition(index); + const imageDTO = useImageAtPosition(index); - if (image) { - return ; + if (imageDTO) { + return ; } return ; @@ -117,7 +185,15 @@ const ImageAtPosition = memo(({ index }: { index: number }) => { ImageAtPosition.displayName = 'ImageAtPosition'; export const NewGallery = memo(() => { - const { positionInfo, countsLoading } = useVirtualImageData(); + const { positionInfo, isLoading, updateRequiredRanges } = useVirtualImageData(); + + // Handle range changes from VirtuosoGrid + const handleRangeChanged = useCallback( + (range: { startIndex: number; endIndex: number }) => { + updateRequiredRanges(range.startIndex, range.endIndex); + }, + [updateRequiredRanges] + ); // Render item at specific index const itemContent = useCallback((index: number) => { @@ -127,7 +203,7 @@ export const NewGallery = memo(() => { // Compute item key using position index - let RTK Query handle the caching const computeItemKey = useCallback((index: number) => `position-${index}`, []); - if (countsLoading) { + if (isLoading) { return ( @@ -146,10 +222,10 @@ export const NewGallery = memo(() => { return ( - {/* Virtualized grid */} ( - -)); +const ListComponent = forwardRef((props, ref) => { + const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); + + return ( + + ); +}); const ItemComponent = forwardRef((props, ref) => ); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index f3d867b7d3..23d5de493e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -26,7 +26,8 @@ import { buildBoardsUrl } from './boards'; * buildImagesUrl('some-path') * // '/api/v1/images/some-path' */ -const buildImagesUrl = (path: string = '') => buildV1Url(`images/${path}`); +const buildImagesUrl = (path: string = '', query?: Parameters[1]) => + buildV1Url(`images/${path}`, query); /** * Builds an endpoint URL for the board_images router @@ -428,9 +429,8 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/images/collections/counts']['get']['parameters']['query'] >({ query: (queryArgs) => ({ - url: buildImagesUrl('collections/counts'), + url: buildImagesUrl('collections/counts', queryArgs), method: 'GET', - params: queryArgs, }), providesTags: ['ImageCollectionCounts', 'FetchOnReconnect'], }), @@ -443,28 +443,27 @@ export const imagesApi = api.injectEndpoints({ paths['/api/v1/images/collections/{collection}']['get']['parameters']['query'] >({ query: ({ collection, ...queryArgs }) => ({ - url: buildImagesUrl(`collections/${collection}`), + url: buildImagesUrl(`collections/${collection}`, queryArgs), method: 'GET', - params: queryArgs, }), providesTags: (result, error, { collection, board_id, categories }) => { const cacheKey = `${collection}-${board_id || 'all'}-${categories?.join(',') || 'all'}`; return [{ type: 'ImageCollection', id: cacheKey }, 'FetchOnReconnect']; }, - async onQueryStarted(_, { dispatch, queryFulfilled }) { - // Populate the getImageDTO cache with these images, similar to listImages - const res = await queryFulfilled; - const imageDTOs = res.data.items; - const updates: Param0 = []; - for (const imageDTO of imageDTOs) { - updates.push({ - endpointName: 'getImageDTO', - arg: imageDTO.image_name, - value: imageDTO, - }); - } - dispatch(imagesApi.util.upsertQueryEntries(updates)); - }, + // async onQueryStarted(_, { dispatch, queryFulfilled }) { + // // Populate the getImageDTO cache with these images, similar to listImages + // const res = await queryFulfilled; + // const imageDTOs = res.data.items; + // const updates: Param0 = []; + // for (const imageDTO of imageDTOs) { + // updates.push({ + // endpointName: 'getImageDTO', + // arg: imageDTO.image_name, + // value: imageDTO, + // }); + // } + // dispatch(imagesApi.util.upsertQueryEntries(updates)); + // }, }), }), }); diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index b2b65b4d99..a5d3ddbec8 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -10,6 +10,7 @@ import { buildCreateApi, coreModule, fetchBaseQuery, reactHooksModule } from '@r import { $authToken } from 'app/store/nanostores/authToken'; import { $baseUrl } from 'app/store/nanostores/baseUrl'; import { $projectId } from 'app/store/nanostores/projectId'; +import queryString from 'query-string'; const tagTypes = [ 'AppVersion', @@ -133,5 +134,10 @@ function getCircularReplacer() { }; } -export const buildV1Url = (path: string): string => `api/v1/${path}`; +export const buildV1Url = (path: string, query?: Parameters[0]): string => { + if (!query) { + return `api/v1/${path}`; + } + return `api/v1/${path}?${queryString.stringify(query)}`; +}; export const buildV2Url = (path: string): string => `api/v2/${path}`; From 32a5e9652a44101998e6483554b0536a5bb429c2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 19:58:59 +1000 Subject: [PATCH 151/210] refactor: gallery scroll (improved impl) --- .../app/services/images/images_default.py | 1 + .../app/components/ThemeLocaleProvider.tsx | 1 + .../listeners/galleryImageClicked.ts | 81 +++- invokeai/frontend/web/src/app/store/store.ts | 2 + .../OverlayScrollbars/overlayscrollbars.css | 14 +- .../features/gallery/components/Gallery.tsx | 21 +- .../components/ImageGrid/GallerySearch.tsx | 15 +- .../ImageGrid/useGallerySearchTerm.ts | 22 +- .../gallery/components/NewGallery.tsx | 356 +++++++++--------- 9 files changed, 289 insertions(+), 224 deletions(-) diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 83809ad3f4..4a40da1ec9 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -1,3 +1,4 @@ +from time import time from typing import Literal, Optional from PIL.Image import Image as PILImageType diff --git a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx index 1cdddb76a5..25ee01903b 100644 --- a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx +++ b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx @@ -1,6 +1,7 @@ import '@fontsource-variable/inter'; 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 type { ReactNode } from 'react'; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index 4356b77e69..c3aa638143 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -1,9 +1,72 @@ import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import type { RootState } from 'app/store/store'; +import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { uniq } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; +import type { ImageCategory, ImageDTO, SQLiteDirection } from 'services/api/types'; + +// Type for image collection query arguments +type ImageCollectionQueryArgs = { + board_id?: string; + categories?: ImageCategory[]; + search_term?: string; + order_dir?: SQLiteDirection; + is_intermediate: boolean; +}; + +/** + * Helper function to get all cached image data from collection queries + * Returns a combined array of starred images followed by unstarred images + */ +const getCachedImageList = (state: RootState, queryArgs: ImageCollectionQueryArgs): ImageDTO[] => { + const countsQueryResult = imagesApi.endpoints.getImageCollectionCounts.select(queryArgs)(state); + + if (!countsQueryResult.data) { + return []; + } + + const starredCount = countsQueryResult.data.starred_count ?? 0; + const totalCount = countsQueryResult.data.total_count ?? 0; + const unstarredCount = totalCount - starredCount; + + const imageDTOs: ImageDTO[] = []; + + // Add starred images first (in order) + if (starredCount > 0) { + for (let offset = 0; offset < starredCount; offset += 50) { + const queryResult = imagesApi.endpoints.getImageCollection.select({ + collection: 'starred', + offset, + limit: 50, + ...queryArgs, + })(state); + + if (queryResult.data?.items) { + imageDTOs.push(...queryResult.data.items); + } + } + } + + // Add unstarred images (in order) + if (unstarredCount > 0) { + for (let offset = 0; offset < unstarredCount; offset += 50) { + const queryResult = imagesApi.endpoints.getImageCollection.select({ + collection: 'unstarred', + offset, + limit: 50, + ...queryArgs, + })(state); + + if (queryResult.data?.items) { + imageDTOs.push(...queryResult.data.items); + } + } + } + + return imageDTOs; +}; export const galleryImageClicked = createAction<{ imageName: string; @@ -30,15 +93,21 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen effect: (action, { dispatch, getState }) => { const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload; const state = getState(); - const queryArgs = selectListImagesQueryArgs(state); - const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state); + const queryArgs = selectImageCollectionQueryArgs(state); - if (!queryResult.data) { - // Should never happen if we have clicked a gallery image + // Get all cached image data + const imageDTOs = getCachedImageList(state, queryArgs); + + // If we don't have the image data cached, we can't perform selection operations + // This can happen if the user clicks on an image before all data is loaded + if (imageDTOs.length === 0) { + // For basic click without modifiers, we can still set selection + if (!shiftKey && !ctrlKey && !metaKey && !altKey) { + dispatch(selectionChanged([imageName])); + } return; } - const imageDTOs = queryResult.data.items; const selection = state.gallery.selection; if (altKey) { diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index ec757494f5..d1a029f1b6 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -170,6 +170,8 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => reducer: rememberedRootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ + // serializableCheck: false, + // immutableCheck: false, serializableCheck: import.meta.env.MODE === 'development', immutableCheck: import.meta.env.MODE === 'development', }) diff --git a/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css b/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css index 8827987401..e5500cbcd5 100644 --- a/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css +++ b/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css @@ -1,6 +1,6 @@ .os-scrollbar { /* The size of the scrollbar */ - --os-size: 9px; + --os-size: 7px; /* The axis-perpedicular padding of the scrollbar (horizontal: padding-y, vertical: padding-x) */ /* --os-padding-perpendicular: 0; */ /* The axis padding of the scrollbar (horizontal: padding-x, vertical: padding-y) */ @@ -22,11 +22,11 @@ /* The border radius of the scrollbar handle */ /* --os-handle-border-radius: 2px; */ /* The background of the scrollbar handle */ - /* --os-handle-bg: var(--invokeai-colors-accentAlpha-500); */ + --os-handle-bg: var(--invoke-colors-base-600); /* The :hover background of the scrollbar handle */ - /* --os-handle-bg-hover: var(--invokeai-colors-accentAlpha-700); */ + --os-handle-bg-hover: var(--invoke-colors-base-500); /* The :active background of the scrollbar handle */ - /* --os-handle-bg-active: var(--invokeai-colors-accentAlpha-800); */ + --os-handle-bg-active: var(--invoke-colors-base-400); /* The border of the scrollbar handle */ /* --os-handle-border: none; */ /* The :hover border of the scrollbar handle */ @@ -34,7 +34,7 @@ /* The :active border of the scrollbar handle */ /* --os-handle-border-active: none; */ /* The min size of the scrollbar handle */ - --os-handle-min-size: 50px; + /* --os-handle-min-size: 50px; */ /* The max size of the scrollbar handle */ /* --os-handle-max-size: none; */ /* The axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */ @@ -48,9 +48,9 @@ } .os-scrollbar-handle { - cursor: grab; + /* cursor: grab; */ } .os-scrollbar-handle:active { - cursor: grabbing; + /* cursor: grabbing; */ } diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index ec8f15f72e..2ffcd5ca7c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -70,7 +70,7 @@ export const GalleryPanel = memo(() => { return ( - + {boardName} @@ -89,6 +89,7 @@ export const GalleryPanel = memo(() => { + { /> + + + + + - - - - - {/* */} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx index 0ab0f17894..7159236e26 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx @@ -1,11 +1,10 @@ import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { useDebouncedImageCollectionQueryArgs } from 'features/gallery/components/NewGallery'; import type { ChangeEvent, KeyboardEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -import { useListImagesQuery } from 'services/api/endpoints/images'; +import { useGetImageCollectionCountsQuery } from 'services/api/endpoints/images'; type Props = { searchTerm: string; @@ -15,10 +14,8 @@ type Props = { export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { const { t } = useTranslation(); - const queryArgs = useAppSelector(selectListImagesQueryArgs); - const { isPending } = useListImagesQuery(queryArgs, { - selectFromResult: ({ isLoading, isFetching }) => ({ isPending: isLoading || isFetching }), - }); + const queryArgs = useDebouncedImageCollectionQueryArgs(); + const { isFetching } = useGetImageCollectionCountsQuery(queryArgs); const handleChangeInput = useCallback( (e: ChangeEvent) => { @@ -46,12 +43,12 @@ export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSear data-testid="image-search-input" onKeyDown={handleKeydown} /> - {isPending && ( + {isFetching && ( )} - {!isPending && searchTerm.length && ( + {!isFetching && searchTerm.length && ( { // Highlander! @@ -11,27 +10,16 @@ export const useGallerySearchTerm = () => { const dispatch = useAppDispatch(); const searchTerm = useAppSelector(selectSearchTerm); - const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); - - const debouncedSetSearchTerm = useMemo(() => { - return debounce((val: string) => { - dispatch(searchTermChanged(val)); - }, 1000); - }, [dispatch]); - const onChange = useCallback( (val: string) => { - setLocalSearchTerm(val); - debouncedSetSearchTerm(val); + dispatch(searchTermChanged(val)); }, - [debouncedSetSearchTerm] + [dispatch] ); const onReset = useCallback(() => { - debouncedSetSearchTerm.cancel(); - setLocalSearchTerm(''); dispatch(searchTermChanged('')); - }, [debouncedSetSearchTerm, dispatch]); + }, [dispatch]); - return [localSearchTerm, onChange, onReset] as const; + return [searchTerm, onChange, onReset] as const; }; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 9837db60d0..9b5180080a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,207 +1,190 @@ -import { Box, Flex, forwardRef, Grid, GridItem, Image, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; +import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { selectGalleryImageMinimumWidth, selectImageCollectionQueryArgs, } from 'features/gallery/store/gallerySelectors'; -import { memo, useCallback } from 'react'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { GridComponents, ListRange, ScrollSeekConfiguration, VirtuosoGridHandle } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; -import { - useGetImageCollectionCountsQuery, - useGetImageCollectionQuery, - useLazyGetImageCollectionQuery, -} from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; +import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery } from 'services/api/endpoints/images'; +import type { ImageCategory, SQLiteDirection } from 'services/api/types'; +import { useDebounce } from 'use-debounce'; -// Types for range management +import { GalleryImage } from './ImageGrid/GalleryImage'; + +// Type for image collection query arguments +type ImageCollectionQueryArgs = { + board_id?: string; + categories?: ImageCategory[]; + search_term?: string; + order_dir?: SQLiteDirection; + is_intermediate: boolean; +}; + +// Types type Collection = 'starred' | 'unstarred'; -interface RangeKey { +interface PositionInfo { collection: Collection; offset: number; - limit: number; + itemIndex: number; } -interface PositionQuery extends RangeKey { - imageIndex: number; -} - -type PositionInfo = { - totalCount: number; - starredCount: number; - unstarredCount: number; - starredEnd: number; -}; - -// Query options factory functions to prevent recreation on every render -const countsQueryOptions = { - selectFromResult: ({ data, isLoading }) => { - const positionInfo: PositionInfo | null = data - ? { - totalCount: data.total_count ?? 0, - starredCount: data.starred_count ?? 0, - unstarredCount: data.unstarred_count ?? 0, - starredEnd: (data.starred_count ?? 0) - 1, - } - : null; +// Constants +const RANGE_SIZE = 50; +// Helper to calculate which collection and range an index belongs to +const getPositionInfo = (index: number, starredCount: number): PositionInfo => { + if (index < starredCount) { + // Starred collection + const offset = Math.floor(index / RANGE_SIZE) * RANGE_SIZE; return { - positionInfo, - isLoading, + collection: 'starred', + offset, + itemIndex: index - offset, }; - }, -} satisfies Parameters[1]; - -const createImageCollectionQueryOptions = (queryParams: PositionQuery | null) => - ({ - skip: !queryParams, - selectFromResult: (result) => { - return { - imageDTO: (queryParams && result.data?.items?.[queryParams.imageIndex]) || null, - }; - }, - }) satisfies Parameters[1]; - -// Placeholder image component for now -const ImagePlaceholder = memo(({ imageDTO }: { imageDTO: ImageDTO }) => ( - -)); - -ImagePlaceholder.displayName = 'ImagePlaceholder'; - -// Loading skeleton component -const ImageSkeleton = memo(() => ); - -ImageSkeleton.displayName = 'ImageSkeleton'; - -// Hook to manage position calculations and range loading -const useVirtualImageData = () => { - const queryArgs = useAppSelector(selectImageCollectionQueryArgs); - - // Get position info derived from counts using selectFromResult - const { positionInfo, isLoading } = useGetImageCollectionCountsQuery(queryArgs, countsQueryOptions); - - const [triggerGetImageCollection] = useLazyGetImageCollectionQuery(); - - // Function to get query params for a specific position - const getQueryParamsForPosition = useCallback( - (index: number): PositionQuery | null => { - if (!positionInfo) { - return null; - } - - if (positionInfo.starredCount === 0 || index >= positionInfo.starredCount) { - // This position is in the unstarred collection - const unstarredOffset = index - positionInfo.starredCount; - const rangeOffset = Math.floor(unstarredOffset / 50) * 50; - return { - collection: 'unstarred', - offset: rangeOffset, - limit: 50, - imageIndex: unstarredOffset % 50, - }; - } else { - // This position is in the starred collection - const rangeOffset = Math.floor(index / 50) * 50; - return { - collection: 'starred', - offset: rangeOffset, - limit: 50, - imageIndex: index % 50, - }; - } - }, - [positionInfo] - ); - - // Function to calculate required ranges for a viewport and trigger lazy queries - const updateRequiredRanges = useCallback( - (startIndex: number, endIndex: number) => { - if (!positionInfo) { - return; - } - - for (let i = startIndex; i <= endIndex; i++) { - const queryParams = getQueryParamsForPosition(i); - if (queryParams) { - const { collection, offset, limit } = queryParams; - triggerGetImageCollection( - { - collection, - offset, - limit, - ...queryArgs, - }, - true - ); - } - } - }, - [positionInfo, getQueryParamsForPosition, triggerGetImageCollection, queryArgs] - ); - - return { - positionInfo, - isLoading, - getQueryParamsForPosition, - queryArgs, - updateRequiredRanges, - }; + } else { + // Unstarred collection + const unstarredIndex = index - starredCount; + const offset = Math.floor(unstarredIndex / RANGE_SIZE) * RANGE_SIZE; + return { + collection: 'unstarred', + offset, + itemIndex: unstarredIndex - offset, + }; + } }; -// Hook to get image data for a specific position using selectFromResult -const useImageAtPosition = (index: number) => { - const { getQueryParamsForPosition, queryArgs } = useVirtualImageData(); +// Hook to get image at a specific position +const useImageAtPosition = (index: number, starredCount: number, queryArgs: ImageCollectionQueryArgs) => { + const positionInfo = useMemo(() => getPositionInfo(index, starredCount), [index, starredCount]); - const queryParams = getQueryParamsForPosition(index); - - const { imageDTO } = useGetImageCollectionQuery( - queryParams - ? { - collection: queryParams.collection, - offset: queryParams.offset, - limit: queryParams.limit, - ...queryArgs, - } - : skipToken, - createImageCollectionQueryOptions(queryParams) + const arg = useMemo( + () => + ({ + collection: positionInfo.collection, + offset: positionInfo.offset, + limit: RANGE_SIZE, + ...queryArgs, + }) satisfies Parameters[0], + [positionInfo.collection, positionInfo.offset, queryArgs] ); + const options = useMemo( + () => + ({ + selectFromResult: ({ data }) => { + if (!data) { + return { imageDTO: null }; + } else { + return { + imageDTO: data.items[positionInfo.itemIndex] || null, + }; + } + }, + }) satisfies Parameters[1], + [positionInfo.itemIndex] + ); + + const { imageDTO } = useGetImageCollectionQuery(arg, options); + return imageDTO; }; -// Component to render a single image at a position -const ImageAtPosition = memo(({ index }: { index: number }) => { - const imageDTO = useImageAtPosition(index); +type ImageAtPositionProps = { + index: number; + starredCount: number; + queryArgs: ImageCollectionQueryArgs; +}; - if (imageDTO) { - return ; +// Individual image component +const ImageAtPosition = memo(({ index, starredCount, queryArgs }: ImageAtPositionProps) => { + const imageDTO = useImageAtPosition(index, starredCount, queryArgs); + + if (!imageDTO) { + return ; } - return ; + return ; }); ImageAtPosition.displayName = 'ImageAtPosition'; -export const NewGallery = memo(() => { - const { positionInfo, isLoading, updateRequiredRanges } = useVirtualImageData(); +export const useDebouncedImageCollectionQueryArgs = () => { + const _queryArgs = useAppSelector(selectImageCollectionQueryArgs); + const [queryArgs] = useDebounce(_queryArgs, 500); + return queryArgs; +}; - // Handle range changes from VirtuosoGrid - const handleRangeChanged = useCallback( - (range: { startIndex: number; endIndex: number }) => { - updateRequiredRanges(range.startIndex, range.endIndex); +// Main gallery component +export const NewGallery = memo(() => { + const queryArgs = useDebouncedImageCollectionQueryArgs(); + const virtuosoRef = useRef(null); + + const { data: counts, isLoading } = useGetImageCollectionCountsQuery(queryArgs); + + const starredCount = counts?.starred_count ?? 0; + const totalCount = counts?.total_count ?? 0; + + // Reset scroll position when query parameters change + useEffect(() => { + if (virtuosoRef.current && totalCount > 0) { + virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' }); + } + }, [queryArgs, totalCount]); + + // Memoized item content function + const itemContent = useCallback( + (index: number) => { + return ; }, - [updateRequiredRanges] + [starredCount, queryArgs] ); - // Render item at specific index - const itemContent = useCallback((index: number) => { - return ; + // Memoized compute key function + const computeItemKey = useCallback( + (index: number) => { + return `${JSON.stringify(queryArgs)}-${index}`; + }, + [queryArgs] + ); + + // Handle range changes (for prefetching) + const handleRangeChanged = useCallback((_range: ListRange) => { + // RTK Query will automatically handle caching and deduplication + // No need to manually trigger queries here }, []); - // Compute item key using position index - let RTK Query handle the caching - const computeItemKey = useCallback((index: number) => `position-${index}`, []); + const rootRef = useRef(null); + const [scroller, setScroller] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + events: { + initialized(osInstance) { + // force overflow styles + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + viewport.style.overflowY = `var(--os-viewport-overflow-y)`; + }, + }, + }); + + useEffect(() => { + const { current: root } = rootRef; + + if (scroller && root) { + initialize({ + target: root, + elements: { + viewport: scroller, + }, + }); + } + + return () => osInstance()?.destroy(); + }, [scroller, initialize, osInstance]); if (isLoading) { return ( @@ -212,7 +195,7 @@ export const NewGallery = memo(() => { ); } - if (!positionInfo || positionInfo.totalCount === 0) { + if (totalCount === 0) { return ( No images found @@ -221,15 +204,18 @@ export const NewGallery = memo(() => { } return ( - + ); @@ -237,9 +223,18 @@ export const NewGallery = memo(() => { NewGallery.displayName = 'NewGallery'; +const scrollSeekConfiguration: ScrollSeekConfiguration = { + enter: (velocity) => { + return velocity > 500; + }, + exit: (velocity) => velocity < 500, +}; + +// Styles const style = { height: '100%', width: '100%' }; -const ListComponent = forwardRef((props, ref) => { +// Grid components +const ListComponent: GridComponents['List'] = forwardRef((props, ref) => { const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); return ( @@ -247,15 +242,26 @@ const ListComponent = forwardRef((props, ref) => { ref={ref} gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`} gap={2} - padding={2} {...props} /> ); }); +ListComponent.displayName = 'ListComponent'; -const ItemComponent = forwardRef((props, ref) => ); +const ItemComponent: GridComponents['Item'] = forwardRef((props, ref) => ( + +)); +ItemComponent.displayName = 'ItemComponent'; -const components = { +const FillSkeleton: GridComponents['ScrollSeekPlaceholder'] = forwardRef((props, ref) => ( + + + +)); +FillSkeleton.displayName = 'FillSkeleton'; + +const components: GridComponents = { Item: ItemComponent, List: ListComponent, + ScrollSeekPlaceholder: FillSkeleton, }; From 0a8f647260d429be04884f230616c1f8800ea407 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 20:46:45 +1000 Subject: [PATCH 152/210] refactor: gallery scroll (improved impl) --- invokeai/app/api/routers/images.py | 8 +- .../image_records/image_records_base.py | 3 +- .../image_records/image_records_common.py | 6 +- .../image_records/image_records_sqlite.py | 9 +- invokeai/app/services/images/images_base.py | 3 +- .../app/services/images/images_default.py | 3 +- .../listeners/galleryImageClicked.ts | 12 +- .../gallery/components/NewGallery.tsx | 130 +++++++++++------- .../web/src/services/api/endpoints/images.ts | 40 +++--- .../frontend/web/src/services/api/schema.ts | 17 ++- 10 files changed, 139 insertions(+), 92 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index d224242a56..fce598f1f4 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -14,6 +14,7 @@ from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_i from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, + ImageCollectionCounts, ImageRecordChanges, ResourceOrigin, ) @@ -564,7 +565,7 @@ async def get_bulk_download_item( raise HTTPException(status_code=404) -@images_router.get("/collections/counts", operation_id="get_image_collection_counts") +@images_router.get("/collections/counts", operation_id="get_image_collection_counts", response_model=ImageCollectionCounts) async def get_image_collection_counts( image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to count."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), @@ -574,18 +575,17 @@ async def get_image_collection_counts( description="The board id to filter by. Use 'none' to find images without a board.", ), search_term: Optional[str] = Query(default=None, description="The term to search for"), -) -> dict[str, int]: +) -> ImageCollectionCounts: """Gets counts for starred and unstarred image collections""" try: - counts = ApiDependencies.invoker.services.images.get_collection_counts( + return ApiDependencies.invoker.services.images.get_collection_counts( image_origin=image_origin, categories=categories, is_intermediate=is_intermediate, board_id=board_id, search_term=search_term, ) - return counts except Exception: raise HTTPException(status_code=500, detail="Failed to get collection counts") diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index de42fa419d..86fc95fb98 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -5,6 +5,7 @@ from typing import Literal, Optional from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, + ImageCollectionCounts, ImageRecord, ImageRecordChanges, ResourceOrigin, @@ -106,7 +107,7 @@ class ImageRecordStorageBase(ABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, - ) -> dict[str, int]: + ) -> ImageCollectionCounts: """Gets counts for starred and unstarred image collections.""" pass diff --git a/invokeai/app/services/image_records/image_records_common.py b/invokeai/app/services/image_records/image_records_common.py index af681e90e1..231b63957b 100644 --- a/invokeai/app/services/image_records/image_records_common.py +++ b/invokeai/app/services/image_records/image_records_common.py @@ -3,7 +3,7 @@ import datetime from enum import Enum from typing import Optional, Union -from pydantic import Field, StrictBool, StrictStr +from pydantic import BaseModel, Field, StrictBool, StrictStr from invokeai.app.util.metaenum import MetaEnum from invokeai.app.util.misc import get_iso_timestamp @@ -207,3 +207,7 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord: starred=starred, has_workflow=has_workflow, ) + +class ImageCollectionCounts(BaseModel): + starred_count: int = Field(description="The number of starred images in the collection.") + unstarred_count: int = Field(description="The number of unstarred images in the collection.") diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index 592004b793..1a6209eb4c 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -7,6 +7,7 @@ from invokeai.app.services.image_records.image_records_base import ImageRecordSt from invokeai.app.services.image_records.image_records_common import ( IMAGE_DTO_COLS, ImageCategory, + ImageCollectionCounts, ImageRecord, ImageRecordChanges, ImageRecordDeleteException, @@ -394,7 +395,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, - ) -> dict[str, int]: + ) -> ImageCollectionCounts: cursor = self._conn.cursor() # Build the base query conditions (same as get_many) @@ -458,11 +459,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): cursor.execute(unstarred_query, query_params) unstarred_count = cast(int, cursor.fetchone()[0]) - return { - "starred_count": starred_count, - "unstarred_count": unstarred_count, - "total_count": starred_count + unstarred_count, - } + return ImageCollectionCounts(starred_count=starred_count, unstarred_count=unstarred_count) def get_collection_images( self, diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index dd998e2578..4503f822c0 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -6,6 +6,7 @@ from PIL.Image import Image as PILImageType from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, + ImageCollectionCounts, ImageRecord, ImageRecordChanges, ResourceOrigin, @@ -156,7 +157,7 @@ class ImageServiceABC(ABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, - ) -> dict[str, int]: + ) -> ImageCollectionCounts: """Gets counts for starred and unstarred image collections.""" pass diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 4a40da1ec9..6ab0f1dc8d 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -11,6 +11,7 @@ from invokeai.app.services.image_files.image_files_common import ( ) from invokeai.app.services.image_records.image_records_common import ( ImageCategory, + ImageCollectionCounts, ImageRecord, ImageRecordChanges, ImageRecordDeleteException, @@ -318,7 +319,7 @@ class ImageService(ImageServiceABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, - ) -> dict[str, int]: + ) -> ImageCollectionCounts: try: return self.__invoker.services.image_records.get_collection_counts( image_origin=image_origin, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index c3aa638143..b68920b96a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -27,15 +27,13 @@ const getCachedImageList = (state: RootState, queryArgs: ImageCollectionQueryArg return []; } - const starredCount = countsQueryResult.data.starred_count ?? 0; - const totalCount = countsQueryResult.data.total_count ?? 0; - const unstarredCount = totalCount - starredCount; + const { starred_count, unstarred_count } = countsQueryResult.data; const imageDTOs: ImageDTO[] = []; // Add starred images first (in order) - if (starredCount > 0) { - for (let offset = 0; offset < starredCount; offset += 50) { + if (starred_count > 0) { + for (let offset = 0; offset < starred_count; offset += 50) { const queryResult = imagesApi.endpoints.getImageCollection.select({ collection: 'starred', offset, @@ -50,8 +48,8 @@ const getCachedImageList = (state: RootState, queryArgs: ImageCollectionQueryArg } // Add unstarred images (in order) - if (unstarredCount > 0) { - for (let offset = 0; offset < unstarredCount; offset += 50) { + if (unstarred_count > 0) { + for (let offset = 0; offset < unstarred_count; offset += 50) { const queryResult = imagesApi.endpoints.getImageCollection.select({ collection: 'unstarred', offset, diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 9b5180080a..21162829ad 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -5,8 +5,14 @@ import { selectImageCollectionQueryArgs, } from 'features/gallery/store/gallerySelectors'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { GridComponents, ListRange, ScrollSeekConfiguration, VirtuosoGridHandle } from 'react-virtuoso'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import type { + GridComponents, + GridComputeItemKey, + GridItemContent, + ScrollSeekConfiguration, + VirtuosoGridHandle, +} from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery } from 'services/api/endpoints/images'; import type { ImageCategory, SQLiteDirection } from 'services/api/types'; @@ -99,6 +105,15 @@ type ImageAtPositionProps = { queryArgs: ImageCollectionQueryArgs; }; +type GridContext = { + queryArgs: ImageCollectionQueryArgs; + counts: { + starred_count: number; + unstarred_count: number; + total_count: number; + }; +}; + // Individual image component const ImageAtPosition = memo(({ index, starredCount, queryArgs }: ImageAtPositionProps) => { const imageDTO = useImageAtPosition(index, starredCount, queryArgs); @@ -118,44 +133,46 @@ export const useDebouncedImageCollectionQueryArgs = () => { return queryArgs; }; +const getImageCollectionCountsOptions = { + selectFromResult: ({ data, isLoading }) => ({ + counts: data + ? { + starred_count: data.starred_count, + unstarred_count: data.unstarred_count, + total_count: data.starred_count + data.unstarred_count, + } + : { + starred_count: 0, + unstarred_count: 0, + total_count: 0, + }, + isLoading, + }), +} satisfies Parameters[1]; + +// Memoized item content function +const itemContent: GridItemContent = (index, _item, { queryArgs, counts }) => { + return ; +}; + +// Memoized compute key function +const computeItemKey: GridComputeItemKey = (index, _item, { queryArgs }) => { + return `${JSON.stringify(queryArgs)}-${index}`; +}; + // Main gallery component export const NewGallery = memo(() => { const queryArgs = useDebouncedImageCollectionQueryArgs(); const virtuosoRef = useRef(null); - const { data: counts, isLoading } = useGetImageCollectionCountsQuery(queryArgs); - - const starredCount = counts?.starred_count ?? 0; - const totalCount = counts?.total_count ?? 0; + const { counts, isLoading } = useGetImageCollectionCountsQuery(queryArgs, getImageCollectionCountsOptions); // Reset scroll position when query parameters change useEffect(() => { - if (virtuosoRef.current && totalCount > 0) { + if (virtuosoRef.current && counts.total_count > 0) { virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' }); } - }, [queryArgs, totalCount]); - - // Memoized item content function - const itemContent = useCallback( - (index: number) => { - return ; - }, - [starredCount, queryArgs] - ); - - // Memoized compute key function - const computeItemKey = useCallback( - (index: number) => { - return `${JSON.stringify(queryArgs)}-${index}`; - }, - [queryArgs] - ); - - // Handle range changes (for prefetching) - const handleRangeChanged = useCallback((_range: ListRange) => { - // RTK Query will automatically handle caching and deduplication - // No need to manually trigger queries here - }, []); + }, [counts.total_count, queryArgs]); const rootRef = useRef(null); const [scroller, setScroller] = useState(null); @@ -183,33 +200,44 @@ export const NewGallery = memo(() => { }); } - return () => osInstance()?.destroy(); + return () => { + osInstance()?.destroy(); + }; }, [scroller, initialize, osInstance]); + const context = useMemo( + () => + ({ + counts, + queryArgs, + }) satisfies GridContext, + [counts, queryArgs] + ); + if (isLoading) { return ( - + Loading gallery... ); } - if (totalCount === 0) { + if (counts.total_count === 0) { return ( - No images found + No images found ); } return ( - ref={virtuosoRef} - totalCount={totalCount} + context={context} + totalCount={counts.total_count} increaseViewportBy={1024} - rangeChanged={handleRangeChanged} itemContent={itemContent} computeItemKey={computeItemKey} components={components} @@ -224,17 +252,15 @@ export const NewGallery = memo(() => { NewGallery.displayName = 'NewGallery'; const scrollSeekConfiguration: ScrollSeekConfiguration = { - enter: (velocity) => { - return velocity > 500; - }, - exit: (velocity) => velocity < 500, + enter: (velocity) => velocity > 1000, + exit: (velocity) => velocity === 0, }; // Styles const style = { height: '100%', width: '100%' }; // Grid components -const ListComponent: GridComponents['List'] = forwardRef((props, ref) => { +const ListComponent: GridComponents['List'] = forwardRef((props, ref) => { const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); return ( @@ -248,20 +274,22 @@ const ListComponent: GridComponents['List'] = forwardRef((props, ref) => { }); ListComponent.displayName = 'ListComponent'; -const ItemComponent: GridComponents['Item'] = forwardRef((props, ref) => ( +const ItemComponent: GridComponents['Item'] = forwardRef((props, ref) => ( )); ItemComponent.displayName = 'ItemComponent'; -const FillSkeleton: GridComponents['ScrollSeekPlaceholder'] = forwardRef((props, ref) => ( - - - -)); -FillSkeleton.displayName = 'FillSkeleton'; +const ScrollSeekPlaceholderComponent: GridComponents['ScrollSeekPlaceholder'] = forwardRef( + (props, ref) => ( + + + + ) +); +ScrollSeekPlaceholderComponent.displayName = 'ScrollSeekPlaceholderComponent'; -const components: GridComponents = { +const components: GridComponents = { Item: ItemComponent, List: ListComponent, - ScrollSeekPlaceholder: FillSkeleton, + ScrollSeekPlaceholder: ScrollSeekPlaceholderComponent, }; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 23d5de493e..cb76d47865 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -203,7 +203,8 @@ export const imagesApi = api.injectEndpoints({ ...getTagsToInvalidateForImageMutation(result.starred_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), 'ImageCollectionCounts', - { type: 'ImageCollection', id: LIST_TAG }, + { type: 'ImageCollection', id: 'starred' }, + { type: 'ImageCollection', id: 'unstarred' }, ]; }, }), @@ -227,7 +228,8 @@ export const imagesApi = api.injectEndpoints({ ...getTagsToInvalidateForImageMutation(result.unstarred_images), ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), 'ImageCollectionCounts', - { type: 'ImageCollection', id: LIST_TAG }, + { type: 'ImageCollection', id: 'starred' }, + { type: 'ImageCollection', id: 'unstarred' }, ]; }, }), @@ -448,22 +450,26 @@ export const imagesApi = api.injectEndpoints({ }), providesTags: (result, error, { collection, board_id, categories }) => { const cacheKey = `${collection}-${board_id || 'all'}-${categories?.join(',') || 'all'}`; - return [{ type: 'ImageCollection', id: cacheKey }, 'FetchOnReconnect']; + return [ + { type: 'ImageCollection', id: collection }, + { type: 'ImageCollection', id: cacheKey }, + 'FetchOnReconnect', + ]; + }, + async onQueryStarted(_, { dispatch, queryFulfilled }) { + // Populate the getImageDTO cache with these images, similar to listImages + const res = await queryFulfilled; + const imageDTOs = res.data.items; + const updates: Param0 = []; + for (const imageDTO of imageDTOs) { + updates.push({ + endpointName: 'getImageDTO', + arg: imageDTO.image_name, + value: imageDTO, + }); + } + dispatch(imagesApi.util.upsertQueryEntries(updates)); }, - // async onQueryStarted(_, { dispatch, queryFulfilled }) { - // // Populate the getImageDTO cache with these images, similar to listImages - // const res = await queryFulfilled; - // const imageDTOs = res.data.items; - // const updates: Param0 = []; - // for (const imageDTO of imageDTOs) { - // updates.push({ - // endpointName: 'getImageDTO', - // arg: imageDTO.image_name, - // value: imageDTO, - // }); - // } - // dispatch(imagesApi.util.upsertQueryEntries(updates)); - // }, }), }), }); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index a33e90312c..0c01dacfe0 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -9844,6 +9844,19 @@ export type components = { */ type: "img_channel_offset"; }; + /** ImageCollectionCounts */ + ImageCollectionCounts: { + /** + * Starred Count + * @description The number of starred images in the collection. + */ + starred_count: number; + /** + * Unstarred Count + * @description The number of unstarred images in the collection. + */ + unstarred_count: number; + }; /** * Image Collection Primitive * @description A collection of image primitive values @@ -23741,9 +23754,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: number; - }; + "application/json": components["schemas"]["ImageCollectionCounts"]; }; }; /** @description Validation Error */ From c8254710e6e54cf4f4882d7afff04b804582a738 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:01:29 +1000 Subject: [PATCH 153/210] refactor: gallery scroll (improved impl) --- invokeai/app/api/routers/images.py | 28 +++++++ .../image_records/image_records_base.py | 13 ++++ .../image_records/image_records_sqlite.py | 73 +++++++++++++++++++ invokeai/app/services/images/images_base.py | 13 ++++ .../app/services/images/images_default.py | 22 ++++++ .../listeners/galleryImageClicked.ts | 69 ++++-------------- .../gallery/components/NewGallery.tsx | 10 ++- .../web/src/services/api/endpoints/images.ts | 23 ++++++ 8 files changed, 195 insertions(+), 56 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index fce598f1f4..e3187822d3 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -622,3 +622,31 @@ async def get_image_collection( return image_dtos except Exception: raise HTTPException(status_code=500, detail="Failed to get collection images") + + +@images_router.get("/names", operation_id="get_image_names") +async def get_image_names( + image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), + categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), + is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find images without a board.", + ), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + search_term: Optional[str] = Query(default=None, description="The term to search for"), +) -> list[str]: + """Gets ordered list of all image names (starred first, then unstarred)""" + + try: + image_names = ApiDependencies.invoker.services.images.get_image_names( + order_dir=order_dir, + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + ) + return image_names + except Exception: + raise HTTPException(status_code=500, detail="Failed to get image names") diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 86fc95fb98..e640e3facb 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -126,3 +126,16 @@ class ImageRecordStorageBase(ABC): ) -> OffsetPaginatedResults[ImageRecord]: """Gets images from a specific collection (starred or unstarred).""" pass + + @abstractmethod + def get_image_names( + self, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> list[str]: + """Gets ordered list of all image names (starred first, then unstarred).""" + pass diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index 1a6209eb4c..bf0706d426 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -561,3 +561,76 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): count = cast(int, cursor.fetchone()[0]) return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count) + + def get_image_names( + self, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> list[str]: + cursor = self._conn.cursor() + + # Base query to get image names in order (starred first, then unstarred) + query = """--sql + SELECT images.image_name + FROM images + LEFT JOIN board_images ON board_images.image_name = images.image_name + WHERE 1=1 + """ + + query_conditions = "" + query_params: list[Union[int, str, bool]] = [] + + if image_origin is not None: + query_conditions += """--sql + AND images.image_origin = ? + """ + query_params.append(image_origin.value) + + if categories is not None: + category_strings = [c.value for c in set(categories)] + placeholders = ",".join("?" * len(category_strings)) + query_conditions += f"""--sql + AND images.image_category IN ( {placeholders} ) + """ + for c in category_strings: + query_params.append(c) + + if is_intermediate is not None: + query_conditions += """--sql + AND images.is_intermediate = ? + """ + query_params.append(is_intermediate) + + if board_id == "none": + query_conditions += """--sql + AND board_images.board_id IS NULL + """ + elif board_id is not None: + query_conditions += """--sql + AND board_images.board_id = ? + """ + query_params.append(board_id) + + if search_term: + query_conditions += """--sql + AND ( + images.metadata LIKE ? + OR images.created_at LIKE ? + ) + """ + query_params.append(f"%{search_term.lower()}%") + query_params.append(f"%{search_term.lower()}%") + + # Order by starred first, then by created_at + query += query_conditions + f"""--sql + ORDER BY images.starred DESC, images.created_at {order_dir.value} + """ + + cursor.execute(query, query_params) + result = cast(list[sqlite3.Row], cursor.fetchall()) + + return [row[0] for row in result] diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index 4503f822c0..4886d31cca 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -176,3 +176,16 @@ class ImageServiceABC(ABC): ) -> OffsetPaginatedResults[ImageDTO]: """Gets images from a specific collection (starred or unstarred).""" pass + + @abstractmethod + def get_image_names( + self, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> list[str]: + """Gets ordered list of all image names (starred first, then unstarred).""" + pass diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 6ab0f1dc8d..62a1262dc8 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -376,3 +376,25 @@ class ImageService(ImageServiceABC): except Exception as e: self.__invoker.services.logger.error("Problem getting collection images") raise e + + def get_image_names( + self, + order_dir: SQLiteDirection = SQLiteDirection.Descending, + image_origin: Optional[ResourceOrigin] = None, + categories: Optional[list[ImageCategory]] = None, + is_intermediate: Optional[bool] = None, + board_id: Optional[str] = None, + search_term: Optional[str] = None, + ) -> list[str]: + try: + return self.__invoker.services.image_records.get_image_names( + order_dir=order_dir, + image_origin=image_origin, + categories=categories, + is_intermediate=is_intermediate, + board_id=board_id, + search_term=search_term, + ) + except Exception as e: + self.__invoker.services.logger.error("Problem getting image names") + raise e diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index b68920b96a..c41ef7c654 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -5,7 +5,7 @@ import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySe import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { uniq } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; -import type { ImageCategory, ImageDTO, SQLiteDirection } from 'services/api/types'; +import type { ImageCategory, SQLiteDirection } from 'services/api/types'; // Type for image collection query arguments type ImageCollectionQueryArgs = { @@ -17,53 +17,12 @@ type ImageCollectionQueryArgs = { }; /** - * Helper function to get all cached image data from collection queries - * Returns a combined array of starred images followed by unstarred images + * Helper function to get cached image names list for selection operations + * Returns an ordered array of image names (starred first, then unstarred) */ -const getCachedImageList = (state: RootState, queryArgs: ImageCollectionQueryArgs): ImageDTO[] => { - const countsQueryResult = imagesApi.endpoints.getImageCollectionCounts.select(queryArgs)(state); - - if (!countsQueryResult.data) { - return []; - } - - const { starred_count, unstarred_count } = countsQueryResult.data; - - const imageDTOs: ImageDTO[] = []; - - // Add starred images first (in order) - if (starred_count > 0) { - for (let offset = 0; offset < starred_count; offset += 50) { - const queryResult = imagesApi.endpoints.getImageCollection.select({ - collection: 'starred', - offset, - limit: 50, - ...queryArgs, - })(state); - - if (queryResult.data?.items) { - imageDTOs.push(...queryResult.data.items); - } - } - } - - // Add unstarred images (in order) - if (unstarred_count > 0) { - for (let offset = 0; offset < unstarred_count; offset += 50) { - const queryResult = imagesApi.endpoints.getImageCollection.select({ - collection: 'unstarred', - offset, - limit: 50, - ...queryArgs, - })(state); - - if (queryResult.data?.items) { - imageDTOs.push(...queryResult.data.items); - } - } - } - - return imageDTOs; +const getCachedImageNames = (state: RootState, queryArgs: ImageCollectionQueryArgs): string[] => { + const queryResult = imagesApi.endpoints.getImageNames.select(queryArgs)(state); + return queryResult.data || []; }; export const galleryImageClicked = createAction<{ @@ -93,12 +52,12 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen const state = getState(); const queryArgs = selectImageCollectionQueryArgs(state); - // Get all cached image data - const imageDTOs = getCachedImageList(state, queryArgs); + // Get cached image names for selection operations + const imageNames = getCachedImageNames(state, queryArgs); - // If we don't have the image data cached, we can't perform selection operations - // This can happen if the user clicks on an image before all data is loaded - if (imageDTOs.length === 0) { + // If we don't have the image names cached, we can't perform selection operations + // This can happen if the user clicks on an image before the names are loaded + if (imageNames.length === 0) { // For basic click without modifiers, we can still set selection if (!shiftKey && !ctrlKey && !metaKey && !altKey) { dispatch(selectionChanged([imageName])); @@ -117,13 +76,13 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen } else if (shiftKey) { const rangeEndImageName = imageName; const lastSelectedImage = selection.at(-1); - const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage); - const currentClickedIndex = imageDTOs.findIndex((n) => n.image_name === rangeEndImageName); + const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage); + const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName); if (lastClickedIndex > -1 && currentClickedIndex > -1) { // We have a valid range! const start = Math.min(lastClickedIndex, currentClickedIndex); const end = Math.max(lastClickedIndex, currentClickedIndex); - const imagesToSelect = imageDTOs.slice(start, end + 1).map(({ image_name }) => image_name); + const imagesToSelect = imageNames.slice(start, end + 1); dispatch(selectionChanged(uniq(selection.concat(imagesToSelect)))); } } else if (ctrlKey || metaKey) { diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 21162829ad..b4838ba142 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -14,7 +14,11 @@ import type { VirtuosoGridHandle, } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; -import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery } from 'services/api/endpoints/images'; +import { + useGetImageCollectionCountsQuery, + useGetImageCollectionQuery, + useGetImageNamesQuery, +} from 'services/api/endpoints/images'; import type { ImageCategory, SQLiteDirection } from 'services/api/types'; import { useDebounce } from 'use-debounce'; @@ -167,6 +171,10 @@ export const NewGallery = memo(() => { const { counts, isLoading } = useGetImageCollectionCountsQuery(queryArgs, getImageCollectionCountsOptions); + // Load image names for selection operations - this is lightweight and ensures + // selection operations work even before image data is fully loaded + useGetImageNamesQuery(queryArgs); + // Reset scroll position when query parameters change useEffect(() => { if (virtuosoRef.current && counts.total_count > 0) { diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index cb76d47865..edf8786e32 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -5,11 +5,13 @@ import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/type import type { components, paths } from 'services/api/schema'; import type { GraphAndWorkflowResponse, + ImageCategory, ImageDTO, ImageUploadEntryRequest, ImageUploadEntryResponse, ListImagesArgs, ListImagesResponse, + SQLiteDirection, UploadImageArg, } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; @@ -471,6 +473,26 @@ export const imagesApi = api.injectEndpoints({ dispatch(imagesApi.util.upsertQueryEntries(updates)); }, }), + /** + * Get ordered list of image names for selection operations + */ + getImageNames: build.query< + string[], + { + image_origin?: 'internal' | 'external' | null; + categories?: ImageCategory[] | null; + is_intermediate?: boolean | null; + board_id?: string | null; + search_term?: string | null; + order_dir?: SQLiteDirection; + } + >({ + query: (queryArgs) => ({ + url: buildImagesUrl('names', queryArgs), + method: 'GET', + }), + providesTags: ['ImageNameList', 'FetchOnReconnect'], + }), }), }); @@ -495,6 +517,7 @@ export const { useGetImageCollectionCountsQuery, useGetImageCollectionQuery, useLazyGetImageCollectionQuery, + useGetImageNamesQuery, } = imagesApi; /** From 8327d8677483a95c2c0fa5b4e236dc721e66e158 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:31:37 +1000 Subject: [PATCH 154/210] refactor: gallery scroll (improved impl) --- .../gallery/components/NewGallery.tsx | 235 ++++++++++-------- 1 file changed, 137 insertions(+), 98 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index b4838ba142..ebc5bc1615 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,15 +1,17 @@ import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; +import { logger } from 'app/logging/logger'; import { useAppSelector } from 'app/store/storeHooks'; import { selectGalleryImageMinimumWidth, selectImageCollectionQueryArgs, } from 'features/gallery/store/gallerySelectors'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GridComponents, GridComputeItemKey, GridItemContent, + ListRange, ScrollSeekConfiguration, VirtuosoGridHandle, } from 'react-virtuoso'; @@ -18,12 +20,16 @@ import { useGetImageCollectionCountsQuery, useGetImageCollectionQuery, useGetImageNamesQuery, + useLazyGetImageCollectionQuery, } from 'services/api/endpoints/images'; -import type { ImageCategory, SQLiteDirection } from 'services/api/types'; +import type { ImageCategory, ImageDTO, SQLiteDirection } from 'services/api/types'; +import { objectEntries } from 'tsafe'; import { useDebounce } from 'use-debounce'; import { GalleryImage } from './ImageGrid/GalleryImage'; +const log = logger('gallery'); + // Type for image collection query arguments type ImageCollectionQueryArgs = { board_id?: string; @@ -33,18 +39,21 @@ type ImageCollectionQueryArgs = { is_intermediate: boolean; }; -// Types -type Collection = 'starred' | 'unstarred'; - -interface PositionInfo { - collection: Collection; - offset: number; - itemIndex: number; -} - // Constants const RANGE_SIZE = 50; +type GridContext = { + queryArgs: ImageCollectionQueryArgs; + imageNames: string[]; + starredCount: number; +}; + +type PositionInfo = { + collection: 'starred' | 'unstarred'; + offset: number; + itemIndex: number; +}; + // Helper to calculate which collection and range an index belongs to const getPositionInfo = (index: number, starredCount: number): PositionInfo => { if (index < starredCount) { @@ -67,68 +76,63 @@ const getPositionInfo = (index: number, starredCount: number): PositionInfo => { } }; -// Hook to get image at a specific position -const useImageAtPosition = (index: number, starredCount: number, queryArgs: ImageCollectionQueryArgs) => { - const positionInfo = useMemo(() => getPositionInfo(index, starredCount), [index, starredCount]); +// Hook to get image DTO from batched collection data +const useImageFromBatch = ( + imageName: string, + index: number, + starredCount: number, + queryArgs: ImageCollectionQueryArgs +): ImageDTO | null => { + const { arg, options } = useMemo(() => { + const positionInfo = getPositionInfo(index, starredCount); - const arg = useMemo( - () => - ({ - collection: positionInfo.collection, - offset: positionInfo.offset, - limit: RANGE_SIZE, - ...queryArgs, - }) satisfies Parameters[0], - [positionInfo.collection, positionInfo.offset, queryArgs] - ); + const arg = { + collection: positionInfo.collection, + offset: positionInfo.offset, + limit: RANGE_SIZE, + ...queryArgs, + } satisfies Parameters[0]; - const options = useMemo( - () => - ({ - selectFromResult: ({ data }) => { - if (!data) { - return { imageDTO: null }; - } else { - return { - imageDTO: data.items[positionInfo.itemIndex] || null, - }; - } - }, - }) satisfies Parameters[1], - [positionInfo.itemIndex] - ); + const options = { + selectFromResult: ({ data }) => { + const imageDTO = data?.items?.[positionInfo.itemIndex] || null; + if (imageDTO && imageDTO.image_name !== imageName) { + log.warnOnce(`Image name mismatch at index ${index}: expected ${imageName}, got ${imageDTO.image_name}`); + } + return { imageDTO }; + }, + } satisfies Parameters[1]; + + return { arg, options }; + }, [imageName, index, queryArgs, starredCount]); const { imageDTO } = useGetImageCollectionQuery(arg, options); return imageDTO; }; -type ImageAtPositionProps = { - index: number; - starredCount: number; - queryArgs: ImageCollectionQueryArgs; -}; +// Individual image component that gets its data from batched requests +const ImageAtPosition = memo( + ({ + imageName, + index, + starredCount, + queryArgs, + }: { + imageName: string; + index: number; + starredCount: number; + queryArgs: ImageCollectionQueryArgs; + }) => { + const imageDTO = useImageFromBatch(imageName, index, starredCount, queryArgs); -type GridContext = { - queryArgs: ImageCollectionQueryArgs; - counts: { - starred_count: number; - unstarred_count: number; - total_count: number; - }; -}; + if (!imageDTO) { + return ; + } -// Individual image component -const ImageAtPosition = memo(({ index, starredCount, queryArgs }: ImageAtPositionProps) => { - const imageDTO = useImageAtPosition(index, starredCount, queryArgs); - - if (!imageDTO) { - return ; + return ; } - - return ; -}); - +); ImageAtPosition.displayName = 'ImageAtPosition'; export const useDebouncedImageCollectionQueryArgs = () => { @@ -137,31 +141,52 @@ export const useDebouncedImageCollectionQueryArgs = () => { return queryArgs; }; -const getImageCollectionCountsOptions = { - selectFromResult: ({ data, isLoading }) => ({ - counts: data - ? { - starred_count: data.starred_count, - unstarred_count: data.unstarred_count, - total_count: data.starred_count + data.unstarred_count, - } - : { - starred_count: 0, - unstarred_count: 0, - total_count: 0, - }, - isLoading, - }), -} satisfies Parameters[1]; - -// Memoized item content function -const itemContent: GridItemContent = (index, _item, { queryArgs, counts }) => { - return ; +// Memoized item content function that uses image names as data but batches requests +const itemContent: GridItemContent = (index, imageName, { queryArgs, starredCount }) => { + if (!imageName) { + return ; + } + return ; }; -// Memoized compute key function -const computeItemKey: GridComputeItemKey = (index, _item, { queryArgs }) => { - return `${JSON.stringify(queryArgs)}-${index}`; +// Memoized compute key function using image names +const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { + return `${JSON.stringify(queryArgs)}-${imageName || index}`; +}; + +// Hook to prefetch ranges based on visible area +const usePrefetchRanges = (starredCount: number, queryArgs: ImageCollectionQueryArgs) => { + const [triggerGetImageCollection] = useLazyGetImageCollectionQuery(); + + const prefetchRange = useCallback( + (startIndex: number, endIndex: number) => { + const ranges = { + starred: new Set(), + unstarred: new Set(), + }; + + // Collect all unique ranges needed for the visible area + for (let i = startIndex; i <= endIndex; i++) { + const positionInfo = getPositionInfo(i, starredCount); + ranges[positionInfo.collection].add(positionInfo.offset); + } + + // Trigger queries for each unique range + for (const [collection, offsets] of objectEntries(ranges)) { + for (const offset of offsets) { + triggerGetImageCollection({ + collection, + offset, + limit: RANGE_SIZE, + ...queryArgs, + }); + } + } + }, + [starredCount, queryArgs, triggerGetImageCollection] + ); + + return prefetchRange; }; // Main gallery component @@ -169,18 +194,21 @@ export const NewGallery = memo(() => { const queryArgs = useDebouncedImageCollectionQueryArgs(); const virtuosoRef = useRef(null); - const { counts, isLoading } = useGetImageCollectionCountsQuery(queryArgs, getImageCollectionCountsOptions); + // Get the ordered list of image names - this is our primary data source + const { data: imageNames = [], isLoading } = useGetImageNamesQuery(queryArgs); - // Load image names for selection operations - this is lightweight and ensures - // selection operations work even before image data is fully loaded - useGetImageNamesQuery(queryArgs); + // Get starred count for position calculations + const { data: counts } = useGetImageCollectionCountsQuery(queryArgs); + const starredCount = counts?.starred_count ?? 0; + + const prefetchRange = usePrefetchRanges(starredCount, queryArgs); // Reset scroll position when query parameters change useEffect(() => { - if (virtuosoRef.current && counts.total_count > 0) { + if (virtuosoRef.current && imageNames.length > 0) { virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' }); } - }, [counts.total_count, queryArgs]); + }, [queryArgs, imageNames.length]); const rootRef = useRef(null); const [scroller, setScroller] = useState(null); @@ -213,13 +241,22 @@ export const NewGallery = memo(() => { }; }, [scroller, initialize, osInstance]); + // Handle range changes to prefetch data for visible + buffer areas + const handleRangeChanged = useCallback( + (range: ListRange) => { + prefetchRange(range.startIndex, range.endIndex); + }, + [prefetchRange] + ); + const context = useMemo( () => ({ - counts, + imageNames, queryArgs, + starredCount, }) satisfies GridContext, - [counts, queryArgs] + [imageNames, queryArgs, starredCount] ); if (isLoading) { @@ -231,7 +268,7 @@ export const NewGallery = memo(() => { ); } - if (counts.total_count === 0) { + if (imageNames.length === 0) { return ( No images found @@ -241,17 +278,19 @@ export const NewGallery = memo(() => { return ( - + ref={virtuosoRef} context={context} - totalCount={counts.total_count} - increaseViewportBy={1024} + totalCount={imageNames.length} + data={imageNames} + increaseViewportBy={2048} itemContent={itemContent} computeItemKey={computeItemKey} components={components} style={style} scrollerRef={setScroller} scrollSeekConfiguration={scrollSeekConfiguration} + rangeChanged={handleRangeChanged} /> ); @@ -260,7 +299,7 @@ export const NewGallery = memo(() => { NewGallery.displayName = 'NewGallery'; const scrollSeekConfiguration: ScrollSeekConfiguration = { - enter: (velocity) => velocity > 1000, + enter: (velocity) => velocity > 2048, exit: (velocity) => velocity === 0, }; From f55c593705bf7d0eb0ad0fc02c13bf1474bbfd91 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:35:41 +1000 Subject: [PATCH 155/210] refactor: gallery scroll (improved impl) --- .../web/src/features/gallery/components/NewGallery.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index ebc5bc1615..b6fb9ed6ae 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -41,6 +41,8 @@ type ImageCollectionQueryArgs = { // Constants const RANGE_SIZE = 50; +const VIEWPORT_BUFFER = 2048; +const SCROLL_SEEK_VELOCITY_THRESHOLD = 2048; type GridContext = { queryArgs: ImageCollectionQueryArgs; @@ -283,7 +285,7 @@ export const NewGallery = memo(() => { context={context} totalCount={imageNames.length} data={imageNames} - increaseViewportBy={2048} + increaseViewportBy={VIEWPORT_BUFFER} itemContent={itemContent} computeItemKey={computeItemKey} components={components} @@ -299,7 +301,7 @@ export const NewGallery = memo(() => { NewGallery.displayName = 'NewGallery'; const scrollSeekConfiguration: ScrollSeekConfiguration = { - enter: (velocity) => velocity > 2048, + enter: (velocity) => velocity > SCROLL_SEEK_VELOCITY_THRESHOLD, exit: (velocity) => velocity === 0, }; From 434d8a2b125f94ee7aa9d247662d7304fb543fcd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 23:00:24 +1000 Subject: [PATCH 156/210] refactor: gallery scroll (improved impl) --- .../gallery/components/NewGallery.tsx | 208 +++++++++++++++++- 1 file changed, 202 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index b6fb9ed6ae..f07458b4ca 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,10 +1,12 @@ import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; -import { useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectGalleryImageMinimumWidth, selectImageCollectionQueryArgs, + selectLastSelectedImage, } from 'features/gallery/store/gallerySelectors'; +import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { @@ -43,6 +45,9 @@ type ImageCollectionQueryArgs = { const RANGE_SIZE = 50; const VIEWPORT_BUFFER = 2048; const SCROLL_SEEK_VELOCITY_THRESHOLD = 2048; +const DEBOUNCE_DELAY = 500; +const GRID_GAP = 2; +const SPINNER_OPACITY = 0.3; type GridContext = { queryArgs: ImageCollectionQueryArgs; @@ -99,7 +104,7 @@ const useImageFromBatch = ( selectFromResult: ({ data }) => { const imageDTO = data?.items?.[positionInfo.itemIndex] || null; if (imageDTO && imageDTO.image_name !== imageName) { - log.warnOnce(`Image name mismatch at index ${index}: expected ${imageName}, got ${imageDTO.image_name}`); + log.warn(`Image name mismatch at index ${index}: expected ${imageName}, got ${imageDTO.image_name}`); } return { imageDTO }; }, @@ -139,7 +144,7 @@ ImageAtPosition.displayName = 'ImageAtPosition'; export const useDebouncedImageCollectionQueryArgs = () => { const _queryArgs = useAppSelector(selectImageCollectionQueryArgs); - const [queryArgs] = useDebounce(_queryArgs, 500); + const [queryArgs] = useDebounce(_queryArgs, DEBOUNCE_DELAY); return queryArgs; }; @@ -191,6 +196,193 @@ const usePrefetchRanges = (starredCount: number, queryArgs: ImageCollectionQuery return prefetchRange; }; +// Physical DOM-based grid calculation using refs (based on working old implementation) +const getImagesPerRow = (rootEl: HTMLDivElement): number => { + // Start from root and find virtuoso grid elements + const gridElement = rootEl.querySelector('.virtuoso-grid-list'); + + if (!gridElement) { + return 0; + } + + const firstGridItem = gridElement.querySelector('.virtuoso-grid-item'); + + if (!firstGridItem) { + return 0; + } + + const itemRect = firstGridItem.getBoundingClientRect(); + const containerRect = gridElement.getBoundingClientRect(); + + // Get the computed gap from CSS + const gridStyle = window.getComputedStyle(gridElement); + const gapValue = gridStyle.gap; + const gap = parseFloat(gapValue); + + if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) { + return 0; + } + + // Use the exact calculation from the working old implementation + let imagesPerRow = 0; + let spaceUsed = 0; + + // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes + // this, without the possibility of accidentally adding an extra column. + while (spaceUsed + itemRect.width <= containerRect.width + 1) { + imagesPerRow++; // Increment the number of images + spaceUsed += itemRect.width; // Add image size to the used space + if (spaceUsed + gap <= containerRect.width) { + spaceUsed += gap; // Add gap size to the used space after each image except after the last image + } + } + + return Math.max(1, imagesPerRow); +}; + +// Check if an item at a given index is visible in the viewport +const isItemVisible = (index: number, rootEl: HTMLDivElement): null | 'start' | 'center' | 'end' => { + // First get the virtuoso grid list root element + const gridList = rootEl.querySelector('.virtuoso-grid-list') as HTMLElement; + + if (!gridList) { + return null; + } + + // Then find the specific item within the grid list + const targetItem = gridList.querySelector(`.virtuoso-grid-item[data-index="${index}"]`) as HTMLElement; + + if (!targetItem) { + return null; + } + + const itemRect = targetItem.getBoundingClientRect(); + const rootRect = rootEl.getBoundingClientRect(); + + if (itemRect.top < rootRect.top) { + return 'start'; + } + + if (itemRect.bottom > rootRect.bottom) { + return 'end'; + } + + return 'center'; +}; + +// Hook for keyboard navigation using physical DOM measurements +const useKeyboardNavigation = ( + imageNames: string[], + virtuosoRef: React.RefObject, + rootRef: React.RefObject +) => { + const dispatch = useAppDispatch(); + const lastSelectedImage = useAppSelector(selectLastSelectedImage); + + // Get current index of selected image + const currentIndex = useMemo(() => { + if (!lastSelectedImage || imageNames.length === 0) { + return 0; + } + const index = imageNames.findIndex((name) => name === lastSelectedImage); + return index >= 0 ? index : 0; + }, [lastSelectedImage, imageNames]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const rootEl = rootRef.current; + if (!rootEl) { + return; + } + if (imageNames.length === 0) { + return; + } + + // Only handle arrow keys + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + return; + } + + // Don't interfere if user is typing in an input + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + + const imagesPerRow = getImagesPerRow(rootEl); + + if (imagesPerRow === 0) { + // This can happen if the grid is not yet rendered or has no items + return; + } + + event.preventDefault(); + + let newIndex = currentIndex; + + switch (event.key) { + case 'ArrowLeft': + if (currentIndex > 0) { + newIndex = currentIndex - 1; + } else { + // Wrap to last image + newIndex = imageNames.length - 1; + } + break; + case 'ArrowRight': + if (currentIndex < imageNames.length - 1) { + newIndex = currentIndex + 1; + } else { + // Wrap to first image + newIndex = 0; + } + break; + case 'ArrowUp': + // If on first row, stay on current image + if (currentIndex < imagesPerRow) { + newIndex = currentIndex; + } else { + newIndex = Math.max(0, currentIndex - imagesPerRow); + } + break; + case 'ArrowDown': + // If no images below, stay on current image + if (currentIndex >= imageNames.length - imagesPerRow) { + newIndex = currentIndex; + } else { + newIndex = Math.min(imageNames.length - 1, currentIndex + imagesPerRow); + } + break; + } + + if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) { + const newImageName = imageNames[newIndex]; + if (newImageName) { + dispatch(selectionChanged([newImageName])); + + // Only scroll if the selected item is not visible + const vis = isItemVisible(newIndex, rootEl); + if (!vis || vis === 'center') { + return; + } + virtuosoRef.current?.scrollToIndex({ + index: newIndex, + behavior: 'smooth', + align: vis, + }); + } + } + }, + [rootRef, imageNames, currentIndex, dispatch, virtuosoRef] + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); +}; + // Main gallery component export const NewGallery = memo(() => { const queryArgs = useDebouncedImageCollectionQueryArgs(); @@ -213,6 +405,10 @@ export const NewGallery = memo(() => { }, [queryArgs, imageNames.length]); const rootRef = useRef(null); + + // Enable keyboard navigation + useKeyboardNavigation(imageNames, virtuosoRef, rootRef); + const [scroller, setScroller] = useState(null); const [initialize, osInstance] = useOverlayScrollbars({ defer: true, @@ -264,7 +460,7 @@ export const NewGallery = memo(() => { if (isLoading) { return ( - + Loading gallery... ); @@ -285,7 +481,7 @@ export const NewGallery = memo(() => { context={context} totalCount={imageNames.length} data={imageNames} - increaseViewportBy={VIEWPORT_BUFFER} + overscan={VIEWPORT_BUFFER} itemContent={itemContent} computeItemKey={computeItemKey} components={components} @@ -316,7 +512,7 @@ const ListComponent: GridComponents['List'] = forwardRef((props, re ); From d45197e0af7d9bc2ae839551083427385084c190 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 24 Jun 2025 23:33:37 +1000 Subject: [PATCH 157/210] refactor: gallery scroll (improved impl) --- .../frontend/web/src/features/gallery/components/NewGallery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index f07458b4ca..a663b2514d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -481,7 +481,7 @@ export const NewGallery = memo(() => { context={context} totalCount={imageNames.length} data={imageNames} - overscan={VIEWPORT_BUFFER} + increaseViewportBy={VIEWPORT_BUFFER} itemContent={itemContent} computeItemKey={computeItemKey} components={components} From f68d8ed36a538ac1580684fdcdda57ffde09f350 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 00:38:28 +1000 Subject: [PATCH 158/210] refactor: gallery scroll (improved impl) --- invokeai/app/services/images/images_base.py | 2 +- invokeai/frontend/web/src/app/store/store.ts | 2 - .../gallery/components/NewGallery.tsx | 276 ++++++------------ .../gallery/store/gallerySelectors.ts | 1 + .../web/src/services/api/endpoints/images.ts | 44 ++- 5 files changed, 140 insertions(+), 185 deletions(-) diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index 4886d31cca..037d463e33 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -126,7 +126,7 @@ class ImageServiceABC(ABC): board_id: Optional[str] = None, search_term: Optional[str] = None, ) -> OffsetPaginatedResults[ImageDTO]: - """Gets a paginated list of image DTOs.""" + """Gets a paginated list of image DTOs with starred images first when starred_first=True.""" pass @abstractmethod diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index d1a029f1b6..ec757494f5 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -170,8 +170,6 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => reducer: rememberedRootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ - // serializableCheck: false, - // immutableCheck: false, serializableCheck: import.meta.env.MODE === 'development', immutableCheck: import.meta.env.MODE === 'development', }) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index a663b2514d..ae1d104763 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -8,6 +8,7 @@ import { } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import type { MutableRefObject } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GridComponents, @@ -18,120 +19,64 @@ import type { VirtuosoGridHandle, } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; -import { - useGetImageCollectionCountsQuery, - useGetImageCollectionQuery, - useGetImageNamesQuery, - useLazyGetImageCollectionQuery, -} from 'services/api/endpoints/images'; -import type { ImageCategory, ImageDTO, SQLiteDirection } from 'services/api/types'; -import { objectEntries } from 'tsafe'; +import { useGetImageNamesQuery, useListImagesQuery } from 'services/api/endpoints/images'; +import type { ImageDTO, ListImagesArgs } from 'services/api/types'; import { useDebounce } from 'use-debounce'; import { GalleryImage } from './ImageGrid/GalleryImage'; const log = logger('gallery'); -// Type for image collection query arguments -type ImageCollectionQueryArgs = { - board_id?: string; - categories?: ImageCategory[]; - search_term?: string; - order_dir?: SQLiteDirection; - is_intermediate: boolean; -}; - // Constants -const RANGE_SIZE = 50; +const PAGE_SIZE = 100; const VIEWPORT_BUFFER = 2048; -const SCROLL_SEEK_VELOCITY_THRESHOLD = 2048; +const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096; const DEBOUNCE_DELAY = 500; -const GRID_GAP = 2; +const GRID_GAP = 1; const SPINNER_OPACITY = 0.3; type GridContext = { - queryArgs: ImageCollectionQueryArgs; + queryArgs: ListImagesArgs; imageNames: string[]; - starredCount: number; }; -type PositionInfo = { - collection: 'starred' | 'unstarred'; - offset: number; - itemIndex: number; +export const useDebouncedImageCollectionQueryArgs = () => { + const _galleryQueryArgs = useAppSelector(selectImageCollectionQueryArgs); + const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY); + return queryArgs; }; -// Helper to calculate which collection and range an index belongs to -const getPositionInfo = (index: number, starredCount: number): PositionInfo => { - if (index < starredCount) { - // Starred collection - const offset = Math.floor(index / RANGE_SIZE) * RANGE_SIZE; - return { - collection: 'starred', - offset, - itemIndex: index - offset, - }; - } else { - // Unstarred collection - const unstarredIndex = index - starredCount; - const offset = Math.floor(unstarredIndex / RANGE_SIZE) * RANGE_SIZE; - return { - collection: 'unstarred', - offset, - itemIndex: unstarredIndex - offset, - }; - } -}; - -// Hook to get image DTO from batched collection data -const useImageFromBatch = ( - imageName: string, - index: number, - starredCount: number, - queryArgs: ImageCollectionQueryArgs -): ImageDTO | null => { +// Hook to get an image DTO from cache or trigger loading +const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: ListImagesArgs): ImageDTO | null => { const { arg, options } = useMemo(() => { - const positionInfo = getPositionInfo(index, starredCount); + const pageOffset = Math.floor(index / PAGE_SIZE) * PAGE_SIZE; + return { + arg: { + ...queryArgs, + offset: pageOffset, + limit: PAGE_SIZE, + } satisfies Parameters[0], + options: { + selectFromResult: ({ data }) => { + const imageDTO = data?.items?.[index - pageOffset] || null; + if (imageDTO && imageDTO.image_name !== imageName) { + log.warn(`Image at index ${index} does not match expected image name ${imageName}`); + } + return { imageDTO }; + }, + } satisfies Parameters[1], + }; + }, [index, queryArgs, imageName]); - const arg = { - collection: positionInfo.collection, - offset: positionInfo.offset, - limit: RANGE_SIZE, - ...queryArgs, - } satisfies Parameters[0]; - - const options = { - selectFromResult: ({ data }) => { - const imageDTO = data?.items?.[positionInfo.itemIndex] || null; - if (imageDTO && imageDTO.image_name !== imageName) { - log.warn(`Image name mismatch at index ${index}: expected ${imageName}, got ${imageDTO.image_name}`); - } - return { imageDTO }; - }, - } satisfies Parameters[1]; - - return { arg, options }; - }, [imageName, index, queryArgs, starredCount]); - - const { imageDTO } = useGetImageCollectionQuery(arg, options); + const { imageDTO } = useListImagesQuery(arg, options); return imageDTO; }; -// Individual image component that gets its data from batched requests +// Individual image component that gets its data from RTK Query cache const ImageAtPosition = memo( - ({ - imageName, - index, - starredCount, - queryArgs, - }: { - imageName: string; - index: number; - starredCount: number; - queryArgs: ImageCollectionQueryArgs; - }) => { - const imageDTO = useImageFromBatch(imageName, index, starredCount, queryArgs); + ({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesArgs }) => { + const imageDTO = useImageDTOFromListQuery(index, imageName, queryArgs); if (!imageDTO) { return ; @@ -142,58 +87,9 @@ const ImageAtPosition = memo( ); ImageAtPosition.displayName = 'ImageAtPosition'; -export const useDebouncedImageCollectionQueryArgs = () => { - const _queryArgs = useAppSelector(selectImageCollectionQueryArgs); - const [queryArgs] = useDebounce(_queryArgs, DEBOUNCE_DELAY); - return queryArgs; -}; - -// Memoized item content function that uses image names as data but batches requests -const itemContent: GridItemContent = (index, imageName, { queryArgs, starredCount }) => { - if (!imageName) { - return ; - } - return ; -}; - // Memoized compute key function using image names const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { - return `${JSON.stringify(queryArgs)}-${imageName || index}`; -}; - -// Hook to prefetch ranges based on visible area -const usePrefetchRanges = (starredCount: number, queryArgs: ImageCollectionQueryArgs) => { - const [triggerGetImageCollection] = useLazyGetImageCollectionQuery(); - - const prefetchRange = useCallback( - (startIndex: number, endIndex: number) => { - const ranges = { - starred: new Set(), - unstarred: new Set(), - }; - - // Collect all unique ranges needed for the visible area - for (let i = startIndex; i <= endIndex; i++) { - const positionInfo = getPositionInfo(i, starredCount); - ranges[positionInfo.collection].add(positionInfo.offset); - } - - // Trigger queries for each unique range - for (const [collection, offsets] of objectEntries(ranges)) { - for (const offset of offsets) { - triggerGetImageCollection({ - collection, - offset, - limit: RANGE_SIZE, - ...queryArgs, - }); - } - } - }, - [starredCount, queryArgs, triggerGetImageCollection] - ); - - return prefetchRange; + return `${JSON.stringify(queryArgs)}-${imageName}`; }; // Physical DOM-based grid calculation using refs (based on working old implementation) @@ -241,40 +137,72 @@ const getImagesPerRow = (rootEl: HTMLDivElement): number => { }; // Check if an item at a given index is visible in the viewport -const isItemVisible = (index: number, rootEl: HTMLDivElement): null | 'start' | 'center' | 'end' => { +const scrollIntoView = ( + index: number, + rootEl: HTMLDivElement, + virtuosoGridHandle: VirtuosoGridHandle, + range: ListRange +) => { + if (range.endIndex === 0) { + return; + } + // First get the virtuoso grid list root element const gridList = rootEl.querySelector('.virtuoso-grid-list') as HTMLElement; if (!gridList) { - return null; + // No grid - cannot scroll! + return; } // Then find the specific item within the grid list const targetItem = gridList.querySelector(`.virtuoso-grid-item[data-index="${index}"]`) as HTMLElement; if (!targetItem) { - return null; + if (index > range.endIndex) { + virtuosoGridHandle.scrollToIndex({ + index, + behavior: 'auto', + align: 'start', + }); + } else if (index < range.startIndex) { + virtuosoGridHandle.scrollToIndex({ + index, + behavior: 'auto', + align: 'end', + }); + } else { + log.warn(`Unable to find item index ${index} but it is in range ${range.startIndex}-${range.endIndex}`); + } + return; } const itemRect = targetItem.getBoundingClientRect(); const rootRect = rootEl.getBoundingClientRect(); if (itemRect.top < rootRect.top) { - return 'start'; + virtuosoGridHandle.scrollToIndex({ + index, + behavior: 'auto', + align: 'start', + }); + } else if (itemRect.bottom > rootRect.bottom) { + virtuosoGridHandle.scrollToIndex({ + index, + behavior: 'auto', + align: 'end', + }); } - if (itemRect.bottom > rootRect.bottom) { - return 'end'; - } - - return 'center'; + return; }; // Hook for keyboard navigation using physical DOM measurements const useKeyboardNavigation = ( imageNames: string[], virtuosoRef: React.RefObject, - rootRef: React.RefObject + rootRef: React.RefObject, + rangeRef: MutableRefObject ) => { const dispatch = useAppDispatch(); const lastSelectedImage = useAppSelector(selectLastSelectedImage); @@ -291,7 +219,9 @@ const useKeyboardNavigation = ( const handleKeyDown = useCallback( (event: KeyboardEvent) => { const rootEl = rootRef.current; - if (!rootEl) { + const virtuosoGridHandle = virtuosoRef.current; + const range = rangeRef.current; + if (!rootEl || !virtuosoGridHandle) { return; } if (imageNames.length === 0) { @@ -358,21 +288,11 @@ const useKeyboardNavigation = ( const newImageName = imageNames[newIndex]; if (newImageName) { dispatch(selectionChanged([newImageName])); - - // Only scroll if the selected item is not visible - const vis = isItemVisible(newIndex, rootEl); - if (!vis || vis === 'center') { - return; - } - virtuosoRef.current?.scrollToIndex({ - index: newIndex, - behavior: 'smooth', - align: vis, - }); + scrollIntoView(newIndex, rootEl, virtuosoGridHandle, range); } } }, - [rootRef, imageNames, currentIndex, dispatch, virtuosoRef] + [rootRef, virtuosoRef, rangeRef, imageNames, currentIndex, dispatch] ); useEffect(() => { @@ -387,16 +307,11 @@ const useKeyboardNavigation = ( export const NewGallery = memo(() => { const queryArgs = useDebouncedImageCollectionQueryArgs(); const virtuosoRef = useRef(null); + const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); - // Get the ordered list of image names - this is our primary data source + // Get the ordered list of image names - this is our primary data source for virtualization const { data: imageNames = [], isLoading } = useGetImageNamesQuery(queryArgs); - // Get starred count for position calculations - const { data: counts } = useGetImageCollectionCountsQuery(queryArgs); - const starredCount = counts?.starred_count ?? 0; - - const prefetchRange = usePrefetchRanges(starredCount, queryArgs); - // Reset scroll position when query parameters change useEffect(() => { if (virtuosoRef.current && imageNames.length > 0) { @@ -407,7 +322,7 @@ export const NewGallery = memo(() => { const rootRef = useRef(null); // Enable keyboard navigation - useKeyboardNavigation(imageNames, virtuosoRef, rootRef); + useKeyboardNavigation(imageNames, virtuosoRef, rootRef, rangeRef); const [scroller, setScroller] = useState(null); const [initialize, osInstance] = useOverlayScrollbars({ @@ -439,24 +354,25 @@ export const NewGallery = memo(() => { }; }, [scroller, initialize, osInstance]); - // Handle range changes to prefetch data for visible + buffer areas - const handleRangeChanged = useCallback( - (range: ListRange) => { - prefetchRange(range.startIndex, range.endIndex); - }, - [prefetchRange] - ); + // Handle range changes - RTK Query will automatically cache and manage loading + const handleRangeChanged = useCallback((range: ListRange) => { + rangeRef.current = range; + }, []); const context = useMemo( () => ({ imageNames, queryArgs, - starredCount, }) satisfies GridContext, - [imageNames, queryArgs, starredCount] + [imageNames, queryArgs] ); + // Item content function + const itemContent: GridItemContent = useCallback((index, imageName, ctx) => { + return ; + }, []); + if (isLoading) { return ( diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index 3e132a2d72..ae9affafdc 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -45,6 +45,7 @@ export const selectImageCollectionQueryArgs = createMemoizedSelector(selectGalle search_term: gallery.searchTerm || undefined, order_dir: gallery.orderDir as SQLiteDirection, is_intermediate: false, + starred_first: true, })); export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index edf8786e32..aa72173c7f 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -50,10 +50,10 @@ export const imagesApi = api.injectEndpoints({ url: getListImagesUrl(queryArgs), method: 'GET', }), - providesTags: (result, error, { board_id, categories }) => { + providesTags: (result, error, queryArgs) => { return [ // Make the tags the same as the cache key - { type: 'ImageList', id: getListImagesUrl({ board_id, categories }) }, + { type: 'ImageList', id: JSON.stringify(queryArgs) }, 'FetchOnReconnect', ]; }, @@ -493,6 +493,45 @@ export const imagesApi = api.injectEndpoints({ }), providesTags: ['ImageNameList', 'FetchOnReconnect'], }), + /** + * Get paginated images with starred first (unified list) + */ + getUnifiedImageList: build.query< + ListImagesResponse, + { + offset?: number; + limit?: number; + image_origin?: 'internal' | 'external' | null; + categories?: ImageCategory[] | null; + is_intermediate?: boolean | null; + board_id?: string | null; + search_term?: string | null; + order_dir?: SQLiteDirection; + } + >({ + query: (queryArgs) => ({ + url: getListImagesUrl({ ...queryArgs, starred_first: true }), + method: 'GET', + }), + providesTags: (result, error, { board_id, categories }) => [ + { type: 'ImageList', id: getListImagesUrl({ board_id, categories }) }, + 'FetchOnReconnect', + ], + async onQueryStarted(_, { dispatch, queryFulfilled }) { + // Populate the getImageDTO cache with these images + const res = await queryFulfilled; + const imageDTOs = res.data.items; + const updates: Param0 = []; + for (const imageDTO of imageDTOs) { + updates.push({ + endpointName: 'getImageDTO', + arg: imageDTO.image_name, + value: imageDTO, + }); + } + dispatch(imagesApi.util.upsertQueryEntries(updates)); + }, + }), }), }); @@ -518,6 +557,7 @@ export const { useGetImageCollectionQuery, useLazyGetImageCollectionQuery, useGetImageNamesQuery, + useGetUnifiedImageListQuery, } = imagesApi; /** From adea983bfcd7d77c5a5a4b9392f61258b73a98fc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 00:51:16 +1000 Subject: [PATCH 159/210] refactor: gallery scroll (improved impl) --- .../web/src/features/gallery/components/NewGallery.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index ae1d104763..6d3ee25b8c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,5 +1,6 @@ import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; import { logger } from 'app/logging/logger'; +import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectGalleryImageMinimumWidth, @@ -303,6 +304,13 @@ const useKeyboardNavigation = ( }, [handleKeyDown]); }; +const getImageNamesQueryOptions = { + selectFromResult: ({ data, isLoading }) => ({ + imageNames: data ?? EMPTY_ARRAY, + isLoading, + }), +} satisfies Parameters[1]; + // Main gallery component export const NewGallery = memo(() => { const queryArgs = useDebouncedImageCollectionQueryArgs(); @@ -310,7 +318,7 @@ export const NewGallery = memo(() => { const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); // Get the ordered list of image names - this is our primary data source for virtualization - const { data: imageNames = [], isLoading } = useGetImageNamesQuery(queryArgs); + const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); // Reset scroll position when query parameters change useEffect(() => { From 7080889ed4f333cbc806dcecbbecf5feee983ac4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 01:06:02 +1000 Subject: [PATCH 160/210] feat(ui): scrollbar styles --- .../OverlayScrollbars/overlayscrollbars.css | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css b/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css index e5500cbcd5..96f8a67ccc 100644 --- a/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css +++ b/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css @@ -1,6 +1,6 @@ .os-scrollbar { /* The size of the scrollbar */ - --os-size: 7px; + --os-size: 8px; /* The axis-perpedicular padding of the scrollbar (horizontal: padding-y, vertical: padding-x) */ /* --os-padding-perpendicular: 0; */ /* The axis padding of the scrollbar (horizontal: padding-x, vertical: padding-y) */ @@ -8,11 +8,11 @@ /* The border radius of the scrollbar track */ /* --os-track-border-radius: 0; */ /* The background of the scrollbar track */ - /* --os-track-bg: rgba(0, 0, 0, 0.3); */ + --os-track-bg: rgba(0, 0, 0, 0.5); /* The :hover background of the scrollbar track */ - /* --os-track-bg-hover: rgba(0, 0, 0, 0.3); */ + --os-track-bg-hover: rgba(0, 0, 0, 0.5); /* The :active background of the scrollbar track */ - /* --os-track-bg-active: rgba(0, 0, 0, 0.3); */ + --os-track-bg-active: rgba(0, 0, 0, 0.6); /* The border of the scrollbar track */ /* --os-track-border: none; */ /* The :hover background of the scrollbar track */ @@ -22,11 +22,11 @@ /* The border radius of the scrollbar handle */ /* --os-handle-border-radius: 2px; */ /* The background of the scrollbar handle */ - --os-handle-bg: var(--invoke-colors-base-600); + --os-handle-bg: var(--invoke-colors-base-400); /* The :hover background of the scrollbar handle */ - --os-handle-bg-hover: var(--invoke-colors-base-500); + --os-handle-bg-hover: var(--invoke-colors-base-300); /* The :active background of the scrollbar handle */ - --os-handle-bg-active: var(--invoke-colors-base-400); + --os-handle-bg-active: var(--invoke-colors-base-250); /* The border of the scrollbar handle */ /* --os-handle-border: none; */ /* The :hover border of the scrollbar handle */ @@ -34,17 +34,17 @@ /* The :active border of the scrollbar handle */ /* --os-handle-border-active: none; */ /* The min size of the scrollbar handle */ - /* --os-handle-min-size: 50px; */ + --os-handle-min-size: 32px; /* The max size of the scrollbar handle */ /* --os-handle-max-size: none; */ /* The axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */ /* --os-handle-perpendicular-size: 100%; */ /* The :hover axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */ - /* --os-handle-perpendicular-size-hover: 100%; */ + --os-handle-perpendicular-size-hover: 100%; /* The :active axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */ /* --os-handle-perpendicular-size-active: 100%; */ /* Increases the interactive area of the scrollbar handle. */ - /* --os-handle-interactive-area-offset: 0; */ + --os-handle-interactive-area-offset: -1px; } .os-scrollbar-handle { From bf5fc9512dd8095834857a6631c05e85d0e4658f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 01:10:49 +1000 Subject: [PATCH 161/210] fix(ui): minor jank when siwtching images rapidly --- invokeai/frontend/web/src/services/api/endpoints/images.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index aa72173c7f..503a4acb53 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -661,7 +661,7 @@ export const imageDTOToFile = async (imageDTO: ImageDTO): Promise => { }; export const useImageDTO = (imageName: string | null | undefined) => { - const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); + const { data: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); return imageDTO ?? null; }; From 6e3e316416d56b883e787dd101c13a30871e4a90 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 01:14:12 +1000 Subject: [PATCH 162/210] chore: bump version to v6.0.0a6 --- 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 ebdccfbad6..20405a7741 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "6.0.0a5" +__version__ = "6.0.0a6" From b204fb6a911c0f9cfe67d608f0d0b4e20b01a61b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 01:20:04 +1000 Subject: [PATCH 163/210] chore: ruff --- invokeai/app/api/routers/images.py | 4 +++- invokeai/app/services/image_records/image_records_common.py | 1 + invokeai/app/services/image_records/image_records_sqlite.py | 5 ++++- invokeai/app/services/images/images_default.py | 1 - 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index e3187822d3..d183c614c4 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -565,7 +565,9 @@ async def get_bulk_download_item( raise HTTPException(status_code=404) -@images_router.get("/collections/counts", operation_id="get_image_collection_counts", response_model=ImageCollectionCounts) +@images_router.get( + "/collections/counts", operation_id="get_image_collection_counts", response_model=ImageCollectionCounts +) async def get_image_collection_counts( image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to count."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), diff --git a/invokeai/app/services/image_records/image_records_common.py b/invokeai/app/services/image_records/image_records_common.py index 231b63957b..eee3c0cd9b 100644 --- a/invokeai/app/services/image_records/image_records_common.py +++ b/invokeai/app/services/image_records/image_records_common.py @@ -208,6 +208,7 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord: has_workflow=has_workflow, ) + class ImageCollectionCounts(BaseModel): starred_count: int = Field(description="The number of starred images in the collection.") unstarred_count: int = Field(description="The number of unstarred images in the collection.") diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index bf0706d426..704b99bd77 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -626,9 +626,12 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): query_params.append(f"%{search_term.lower()}%") # Order by starred first, then by created_at - query += query_conditions + f"""--sql + query += ( + query_conditions + + f"""--sql ORDER BY images.starred DESC, images.created_at {order_dir.value} """ + ) cursor.execute(query, query_params) result = cast(list[sqlite3.Row], cursor.fetchall()) diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 62a1262dc8..6585e7ca05 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -1,4 +1,3 @@ -from time import time from typing import Literal, Optional from PIL.Image import Image as PILImageType From 89c609fd61c601ad65318c58ac3a1e40205a33a9 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:46:29 +1000 Subject: [PATCH 164/210] feat(ui): calculate gridTemplateColumns in selector --- .../features/gallery/components/NewGallery.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 6d3ee25b8c..79223a4486 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,4 +1,5 @@ import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; @@ -33,7 +34,6 @@ const PAGE_SIZE = 100; const VIEWPORT_BUFFER = 2048; const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096; const DEBOUNCE_DELAY = 500; -const GRID_GAP = 1; const SPINNER_OPACITY = 0.3; type GridContext = { @@ -428,18 +428,16 @@ const scrollSeekConfiguration: ScrollSeekConfiguration = { // Styles const style = { height: '100%', width: '100%' }; +const selectGridTemplateColumns = createSelector( + selectGalleryImageMinimumWidth, + (galleryImageMinimumWidth) => `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))` +); + // Grid components const ListComponent: GridComponents['List'] = forwardRef((props, ref) => { - const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); + const gridTemplateColumns = useAppSelector(selectGridTemplateColumns); - return ( - - ); + return ; }); ListComponent.displayName = 'ListComponent'; From 1218f49e204c77ae5882a7106e1fcac3ac3a0764 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:48:02 +1000 Subject: [PATCH 165/210] fix(ui): remove context from DOM props --- .../web/src/features/gallery/components/NewGallery.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 79223a4486..f9e2cab7b2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -434,15 +434,15 @@ const selectGridTemplateColumns = createSelector( ); // Grid components -const ListComponent: GridComponents['List'] = forwardRef((props, ref) => { +const ListComponent: GridComponents['List'] = forwardRef(({ context: _, ...rest }, ref) => { const gridTemplateColumns = useAppSelector(selectGridTemplateColumns); - return ; + return ; }); ListComponent.displayName = 'ListComponent'; -const ItemComponent: GridComponents['Item'] = forwardRef((props, ref) => ( - +const ItemComponent: GridComponents['Item'] = forwardRef(({ context: _, ...rest }, ref) => ( + )); ItemComponent.displayName = 'ItemComponent'; From b5eb3d9798b529e678ffdff4f5a17da104b3a351 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 12:19:21 +1000 Subject: [PATCH 166/210] fix(ui): gallery updates on image completion --- .../middleware/listenerMiddleware/index.ts | 2 - .../listeners/galleryImageClicked.ts | 4 +- .../listeners/galleryOffsetChanged.ts | 119 ----------- invokeai/frontend/web/src/app/store/store.ts | 3 +- .../ImageViewer/CurrentImagePreview.tsx | 3 +- .../gallery/components/NewGallery.tsx | 95 +++++---- .../components/NextPrevImageButtons.tsx | 4 - .../gallery/hooks/useGalleryHotkeys.ts | 15 -- .../gallery/store/gallerySelectors.ts | 58 +++--- .../features/gallery/store/gallerySlice.ts | 16 +- .../web/src/features/gallery/store/types.ts | 2 - .../nodes/CurrentImage/CurrentImageNode.tsx | 3 +- .../web/src/services/api/endpoints/images.ts | 4 +- .../frontend/web/src/services/api/index.ts | 2 + .../services/events/onInvocationComplete.tsx | 191 +++++++++--------- 15 files changed, 191 insertions(+), 330 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 856dc31ec7..3629eb345f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -11,7 +11,6 @@ import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddlewar import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; import { addEnsureImageIsSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/ensureImageIsSelectedListener'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; -import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged'; import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema'; import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard'; import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard'; @@ -47,7 +46,6 @@ addDeleteBoardAndImagesFulfilledListener(startAppListening); // Gallery addGalleryImageClickedListener(startAppListening); -addGalleryOffsetChangedListener(startAppListening); // User Invoked addEnqueueRequestedLinear(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index c41ef7c654..f95b7502f0 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -1,7 +1,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import type { RootState } from 'app/store/store'; -import { selectImageCollectionQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { uniq } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; @@ -50,7 +50,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen effect: (action, { dispatch, getState }) => { const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload; const state = getState(); - const queryArgs = selectImageCollectionQueryArgs(state); + const queryArgs = selectListImagesQueryArgs(state); // Get cached image names for selection operations const imageNames = getCachedImageNames(state, queryArgs); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts deleted file mode 100644 index 359fa647d9..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { imageToCompareChanged, offsetChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; -import { imagesApi } from 'services/api/endpoints/images'; - -export const addGalleryOffsetChangedListener = (startAppListening: AppStartListening) => { - /** - * When the user changes pages in the gallery, we need to wait until the next page of images is loaded, then maybe - * update the selection. - * - * There are a three scenarios: - * - * 1. The page is changed by clicking the pagination buttons. No changes to selection are needed. - * - * 2. The page is changed by using the arrow keys (without alt). - * - When going backwards, select the last image. - * - When going forwards, select the first image. - * - * 3. The page is changed by using the arrows keys with alt. This means the user is changing the comparison image. - * - When going backwards, select the last image _as the comparison image_. - * - When going forwards, select the first image _as the comparison image_. - */ - startAppListening({ - actionCreator: offsetChanged, - effect: async (action, { dispatch, getState, getOriginalState, take, cancelActiveListeners }) => { - // Cancel any active listeners to prevent the selection from changing without user input - cancelActiveListeners(); - - const { withHotkey } = action.payload; - - if (!withHotkey) { - // User changed pages by clicking the pagination buttons - no changes to selection - return; - } - - const originalState = getOriginalState(); - const prevOffset = originalState.gallery.offset; - const offset = getState().gallery.offset; - - if (offset === prevOffset) { - // The page didn't change - bail - return; - } - - /** - * We need to wait until the next page of images is loaded before updating the selection, so we use the correct - * page of images. - * - * The simplest way to do it would be to use `take` to wait for the next fulfilled action, but RTK-Q doesn't - * dispatch an action on cache hits. This means the `take` will only return if the cache is empty. If the user - * changes to a cached page - a common situation - the `take` will never resolve. - * - * So we need to take a two-step approach. First, check if we have data in the cache for the page of images. If - * we have data cached, use it to update the selection. If we don't have data cached, wait for the next fulfilled - * action, which updates the cache, then use the cache to update the selection. - */ - - // Check if we have data in the cache for the page of images - const queryArgs = selectListImagesQueryArgs(getState()); - let { data } = imagesApi.endpoints.listImages.select(queryArgs)(getState()); - - // No data yet - wait for the network request to complete - if (!data) { - const takeResult = await take(imagesApi.endpoints.listImages.matchFulfilled, 5000); - if (!takeResult) { - // The request didn't complete in time - bail - return; - } - data = takeResult[0].payload; - } - - // We awaited a network request - state could have changed, get fresh state - const state = getState(); - const { selection, imageToCompare } = state.gallery; - const imageDTOs = data?.items; - - if (!imageDTOs) { - // The page didn't load - bail - return; - } - - if (withHotkey === 'arrow') { - // User changed pages by using the arrow keys - selection changes to first or last image depending - if (offset < prevOffset) { - // We've gone backwards - const lastImage = imageDTOs[imageDTOs.length - 1]; - if (!selection.some((selectedImage) => selectedImage === lastImage?.image_name)) { - dispatch(selectionChanged(lastImage ? [lastImage.image_name] : [])); - } - } else { - // We've gone forwards - const firstImage = imageDTOs[0]; - if (!selection.some((selectedImage) => selectedImage === firstImage?.image_name)) { - dispatch(selectionChanged(firstImage ? [firstImage.image_name] : [])); - } - } - return; - } - - if (withHotkey === 'alt+arrow') { - // User changed pages by using the arrow keys with alt - comparison image changes to first or last depending - if (offset < prevOffset) { - // We've gone backwards - const lastImage = imageDTOs[imageDTOs.length - 1]; - if (lastImage && imageToCompare !== lastImage.image_name) { - dispatch(imageToCompareChanged(lastImage.image_name)); - } - } else { - // We've gone forwards - const firstImage = imageDTOs[0]; - if (firstImage && imageToCompare !== firstImage.image_name) { - dispatch(imageToCompareChanged(firstImage.image_name)); - } - } - return; - } - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index ec757494f5..9397144751 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -39,6 +39,7 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware'; import type { JsonObject } from 'type-fest'; import { STORAGE_PREFIX } from './constants'; +import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware'; import { actionSanitizer } from './middleware/devtools/actionSanitizer'; import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { stateSanitizer } from './middleware/devtools/stateSanitizer'; @@ -176,7 +177,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) - // .concat(getDebugLoggerMiddleware()) + .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index faa0bd91ad..4e57a9b035 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -4,7 +4,6 @@ import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; @@ -115,7 +114,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu left={0} pointerEvents="none" > - + {/* */} )} diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index f9e2cab7b2..36b33fa406 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -5,8 +5,8 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectGalleryImageMinimumWidth, - selectImageCollectionQueryArgs, selectLastSelectedImage, + selectListImagesQueryArgs, } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; @@ -22,7 +22,7 @@ import type { } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; import { useGetImageNamesQuery, useListImagesQuery } from 'services/api/endpoints/images'; -import type { ImageDTO, ListImagesArgs } from 'services/api/types'; +import type { ImageDTO } from 'services/api/types'; import { useDebounce } from 'use-debounce'; import { GalleryImage } from './ImageGrid/GalleryImage'; @@ -30,32 +30,36 @@ import { GalleryImage } from './ImageGrid/GalleryImage'; const log = logger('gallery'); // Constants -const PAGE_SIZE = 100; const VIEWPORT_BUFFER = 2048; const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096; const DEBOUNCE_DELAY = 500; const SPINNER_OPACITY = 0.3; +type ListImagesQueryArgs = ReturnType; + type GridContext = { - queryArgs: ListImagesArgs; + queryArgs: ListImagesQueryArgs; imageNames: string[]; }; export const useDebouncedImageCollectionQueryArgs = () => { - const _galleryQueryArgs = useAppSelector(selectImageCollectionQueryArgs); + const _galleryQueryArgs = useAppSelector(selectListImagesQueryArgs); const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY); return queryArgs; }; // Hook to get an image DTO from cache or trigger loading -const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: ListImagesArgs): ImageDTO | null => { +const useImageDTOFromListQuery = ( + index: number, + imageName: string, + queryArgs: ListImagesQueryArgs +): ImageDTO | null => { const { arg, options } = useMemo(() => { - const pageOffset = Math.floor(index / PAGE_SIZE) * PAGE_SIZE; + const pageOffset = Math.floor(index / queryArgs.limit) * queryArgs.limit; return { arg: { ...queryArgs, offset: pageOffset, - limit: PAGE_SIZE, } satisfies Parameters[0], options: { selectFromResult: ({ data }) => { @@ -76,7 +80,7 @@ const useImageDTOFromListQuery = (index: number, imageName: string, queryArgs: L // Individual image component that gets its data from RTK Query cache const ImageAtPosition = memo( - ({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesArgs }) => { + ({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesQueryArgs }) => { const imageDTO = useImageDTOFromListQuery(index, imageName, queryArgs); if (!imageDTO) { @@ -198,30 +202,27 @@ const scrollIntoView = ( return; }; +const getImageIndex = (imageName: string | undefined, imageNames: string[]) => { + if (!imageName || imageNames.length === 0) { + return 0; + } + const index = imageNames.findIndex((n) => n === imageName); + return index >= 0 ? index : 0; +}; + // Hook for keyboard navigation using physical DOM measurements const useKeyboardNavigation = ( imageNames: string[], virtuosoRef: React.RefObject, - rootRef: React.RefObject, - rangeRef: MutableRefObject + rootRef: React.RefObject ) => { const dispatch = useAppDispatch(); const lastSelectedImage = useAppSelector(selectLastSelectedImage); - // Get current index of selected image - const currentIndex = useMemo(() => { - if (!lastSelectedImage || imageNames.length === 0) { - return 0; - } - const index = imageNames.findIndex((name) => name === lastSelectedImage); - return index >= 0 ? index : 0; - }, [lastSelectedImage, imageNames]); - const handleKeyDown = useCallback( (event: KeyboardEvent) => { const rootEl = rootRef.current; const virtuosoGridHandle = virtuosoRef.current; - const range = rangeRef.current; if (!rootEl || !virtuosoGridHandle) { return; } @@ -248,23 +249,24 @@ const useKeyboardNavigation = ( event.preventDefault(); + const currentIndex = getImageIndex(lastSelectedImage, imageNames); let newIndex = currentIndex; switch (event.key) { case 'ArrowLeft': if (currentIndex > 0) { newIndex = currentIndex - 1; - } else { - // Wrap to last image - newIndex = imageNames.length - 1; + // } else { + // // Wrap to last image + // newIndex = imageNames.length - 1; } break; case 'ArrowRight': if (currentIndex < imageNames.length - 1) { newIndex = currentIndex + 1; - } else { - // Wrap to first image - newIndex = 0; + // } else { + // // Wrap to first image + // newIndex = 0; } break; case 'ArrowUp': @@ -289,11 +291,10 @@ const useKeyboardNavigation = ( const newImageName = imageNames[newIndex]; if (newImageName) { dispatch(selectionChanged([newImageName])); - scrollIntoView(newIndex, rootEl, virtuosoGridHandle, range); } } }, - [rootRef, virtuosoRef, rangeRef, imageNames, currentIndex, dispatch] + [rootRef, virtuosoRef, imageNames, lastSelectedImage, dispatch] ); useEffect(() => { @@ -304,6 +305,30 @@ const useKeyboardNavigation = ( }, [handleKeyDown]); }; +const useKeepSelectedImageInView = ( + imageNames: string[], + virtuosoRef: React.RefObject, + rootRef: React.RefObject, + rangeRef: MutableRefObject +) => { + const imageName = useAppSelector(selectLastSelectedImage); + + useEffect(() => { + const virtuosoGridHandle = virtuosoRef.current; + const rootEl = rootRef.current; + const range = rangeRef.current; + + if (!virtuosoGridHandle || !rootEl || !imageNames || imageNames.length === 0) { + return; + } + const index = imageName ? imageNames.indexOf(imageName) : 0; + if (index === -1) { + return; + } + scrollIntoView(index, rootEl, virtuosoGridHandle, range); + }, [imageName, imageNames, rangeRef, rootRef, virtuosoRef]); +}; + const getImageNamesQueryOptions = { selectFromResult: ({ data, isLoading }) => ({ imageNames: data ?? EMPTY_ARRAY, @@ -316,21 +341,15 @@ export const NewGallery = memo(() => { const queryArgs = useDebouncedImageCollectionQueryArgs(); const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); + const rootRef = useRef(null); // Get the ordered list of image names - this is our primary data source for virtualization const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); - // Reset scroll position when query parameters change - useEffect(() => { - if (virtuosoRef.current && imageNames.length > 0) { - virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'auto' }); - } - }, [queryArgs, imageNames.length]); - - const rootRef = useRef(null); + useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); // Enable keyboard navigation - useKeyboardNavigation(imageNames, virtuosoRef, rootRef, rangeRef); + useKeyboardNavigation(imageNames, virtuosoRef, rootRef); const [scroller, setScroller] = useState(null); const [initialize, osInstance] = useOverlayScrollbars({ diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index 573fae141c..d2187d4f87 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -1,18 +1,14 @@ import type { ChakraProps } from '@invoke-ai/ui-library'; import { Box, IconButton } from '@invoke-ai/ui-library'; import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; -import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation'; -import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { const { t } = useTranslation(); - const { prevImage, nextImage, isOnFirstImageOfView, isOnLastImageOfView } = useGalleryNavigation(); const { isFetching } = useGalleryImages().queryResult; - const { isNextEnabled, goNext, isPrevEnabled, goPrev } = useGalleryPagination(); const shouldShowLeftArrow = useMemo(() => { if (!isOnFirstImageOfView) { diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts index 0f2a175c1c..d386337221 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts @@ -1,8 +1,6 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; -import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation'; -import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; @@ -13,8 +11,6 @@ import { useListImagesQuery } from 'services/api/endpoints/images'; * Registers gallery hotkeys. This hook is a singleton. */ export const useGalleryHotkeys = () => { - // useAssertSingleton('useGalleryHotkeys'); - const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination(); const selection = useAppSelector((s) => s.gallery.selection); const queryArgs = useAppSelector(selectListImagesQueryArgs); const queryResult = useListImagesQuery(queryArgs); @@ -34,17 +30,6 @@ export const useGalleryHotkeys = () => { return canvasRightPanelTab === 'gallery'; }, [appTab, canvasRightPanelTab]); - const { - handleLeftImage, - handleRightImage, - handleUpImage, - handleDownImage, - isOnFirstRow, - isOnLastRow, - isOnFirstImageOfView, - isOnLastImageOfView, - } = useGalleryNavigation(); - useRegisteredHotkeys({ id: 'galleryNavLeft', category: 'gallery', diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index ae9affafdc..f5355a7ba0 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -1,32 +1,13 @@ import { createSelector } from '@reduxjs/toolkit'; -import type { SkipToken } from '@reduxjs/toolkit/query'; -import { skipToken } from '@reduxjs/toolkit/query'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { ListBoardsArgs, ListImagesArgs, SQLiteDirection } from 'services/api/types'; +import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types'; +import type { SetNonNullable } from 'type-fest'; export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); -export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit); -export const selectListImagesQueryArgs = createMemoizedSelector( - selectGallerySlice, - (gallery): ListImagesArgs | SkipToken => - gallery.limit - ? { - board_id: gallery.selectedBoardId, - categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES, - offset: gallery.offset, - limit: gallery.limit, - is_intermediate: false, - starred_first: gallery.starredFirst, - order_dir: gallery.orderDir, - search_term: gallery.searchTerm, - } - : skipToken -); - export const selectListBoardsQueryArgs = createMemoizedSelector( selectGallerySlice, (gallery): ListBoardsArgs => ({ @@ -37,16 +18,35 @@ export const selectListBoardsQueryArgs = createMemoizedSelector( ); export const selectAutoAddBoardId = createSelector(selectGallerySlice, (gallery) => gallery.autoAddBoardId); +export const selectAutoSwitch = createSelector(selectGallerySlice, (gallery) => gallery.shouldAutoSwitch); export const selectSelectedBoardId = createSelector(selectGallerySlice, (gallery) => gallery.selectedBoardId); +export const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); +export const selectGalleryQueryCategories = createSelector(selectGalleryView, (galleryView) => + galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES +); +export const selectGallerySearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); +export const selectGalleryOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.orderDir); +export const selectGalleryStarredFirst = createSelector(selectGallerySlice, (gallery) => gallery.starredFirst); -export const selectImageCollectionQueryArgs = createMemoizedSelector(selectGallerySlice, (gallery) => ({ - board_id: gallery.selectedBoardId === 'none' ? undefined : gallery.selectedBoardId, - categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES, - search_term: gallery.searchTerm || undefined, - order_dir: gallery.orderDir as SQLiteDirection, - is_intermediate: false, - starred_first: true, -})); +export const selectListImagesQueryArgs = createMemoizedSelector( + [ + selectSelectedBoardId, + selectGalleryQueryCategories, + selectGallerySearchTerm, + selectGalleryOrderDir, + selectGalleryStarredFirst, + ], + (board_id, categories, search_term, order_dir, starred_first) => + ({ + board_id, + categories, + search_term, + order_dir, + starred_first, + is_intermediate: false, // We don't show intermediate images in the gallery + limit: 100, // Page size is _always_ 100 + }) satisfies SetNonNullable +); export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, (gallery) => gallery.autoAssignBoardOnClick diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index c21ae398cb..f10d4fd6be 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -16,8 +16,6 @@ const initialGalleryState: GalleryState = { selectedBoardId: 'none', galleryView: 'images', boardSearchText: '', - limit: 20, - offset: 0, starredFirst: true, orderDir: 'DESC', searchTerm: '', @@ -114,7 +112,6 @@ export const gallerySlice = createSlice({ boardIdSelected: (state, action: PayloadAction<{ boardId: BoardId; selectedImageName?: string }>) => { state.selectedBoardId = action.payload.boardId; state.galleryView = 'images'; - state.offset = 0; }, autoAddBoardIdChanged: (state, action: PayloadAction) => { if (!action.payload) { @@ -125,7 +122,6 @@ export const gallerySlice = createSlice({ }, galleryViewChanged: (state, action: PayloadAction) => { state.galleryView = action.payload; - state.offset = 0; }, boardSearchTextChanged: (state, action: PayloadAction) => { state.boardSearchText = action.payload; @@ -143,13 +139,6 @@ export const gallerySlice = createSlice({ comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => { state.comparisonFit = action.payload; }, - offsetChanged: (state, action: PayloadAction<{ offset: number; withHotkey?: 'arrow' | 'alt+arrow' }>) => { - const { offset } = action.payload; - state.offset = offset; - }, - limitChanged: (state, action: PayloadAction) => { - state.limit = action.payload; - }, shouldShowArchivedBoardsChanged: (state, action: PayloadAction) => { state.shouldShowArchivedBoards = action.payload; }, @@ -161,7 +150,6 @@ export const gallerySlice = createSlice({ }, searchTermChanged: (state, action: PayloadAction) => { state.searchTerm = action.payload; - state.offset = 0; }, boardsListOrderByChanged: (state, action: PayloadAction) => { state.boardsListOrderBy = action.payload; @@ -188,8 +176,6 @@ export const { comparedImagesSwapped, comparisonFitChanged, comparisonModeCycled, - offsetChanged, - limitChanged, orderDirChanged, starredFirstChanged, shouldShowArchivedBoardsChanged, @@ -212,5 +198,5 @@ export const galleryPersistConfig: PersistConfig = { name: gallerySlice.name, initialState: initialGalleryState, migrate: migrateGalleryState, - persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'imageToCompare'], + persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'imageToCompare'], }; diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index ad901e6d78..b7a2ee4d11 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -18,8 +18,6 @@ export type GalleryState = { selectedBoardId: BoardId; galleryView: GalleryView; boardSearchText: string; - offset: number; - limit: number; starredFirst: boolean; orderDir: OrderDir; searchTerm: string; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx index 1a1001ab9f..4f228ac240 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx @@ -4,7 +4,6 @@ import type { NodeProps } from '@xyflow/react'; import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { DndImage } from 'features/dnd/DndImage'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper'; import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; @@ -75,7 +74,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => { {props.children} {isHovering && ( - + {/* */} )} diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 503a4acb53..cd7303e069 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -15,6 +15,7 @@ import type { UploadImageArg, } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; +import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -53,7 +54,8 @@ export const imagesApi = api.injectEndpoints({ providesTags: (result, error, queryArgs) => { return [ // Make the tags the same as the cache key - { type: 'ImageList', id: JSON.stringify(queryArgs) }, + { type: 'ImageList', id: stableHash(queryArgs) }, + { type: 'Board', id: queryArgs.board_id ?? 'none' }, 'FetchOnReconnect', ]; }, diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index a5d3ddbec8..02c4d77b6a 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -11,6 +11,7 @@ import { $authToken } from 'app/store/nanostores/authToken'; import { $baseUrl } from 'app/store/nanostores/baseUrl'; import { $projectId } from 'app/store/nanostores/projectId'; import queryString from 'query-string'; +import stableHash from 'stable-hash'; const tagTypes = [ 'AppVersion', @@ -110,6 +111,7 @@ export const api = customCreateApi({ tagTypes, endpoints: () => ({}), invalidationBehavior: 'immediately', + serializeQueryArgs: stableHash, }); function getCircularReplacer() { diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index da6adb2fb6..78322f2408 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -1,17 +1,24 @@ import { logger } from 'app/logging/logger'; +import { addAppListener } from 'app/store/middleware/listenerMiddleware'; import type { AppDispatch, AppGetState } from 'app/store/store'; import { deepClone } from 'common/util/deepClone'; -import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; +import { + selectAutoSwitch, + selectGalleryView, + selectListImagesQueryArgs, + selectSelectedBoardId, +} from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; -import { isCanvasOutputEvent } from 'features/nodes/util/graph/graphBuilderUtils'; import type { ApiTagDescription } from 'services/api'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; -import { getCategories, getListImagesUrl } from 'services/api/util'; +import { getCategories } from 'services/api/util'; import { $lastProgressEvent } from 'services/events/stores'; +import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import { objectEntries } from 'tsafe'; import type { JsonObject } from 'type-fest'; @@ -37,22 +44,25 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi const boardTotalAdditions: Record = {}; const boardTagIdsToInvalidate: Set = new Set(); const imageListTagIdsToInvalidate: Set = new Set(); + const listImagesArg = selectListImagesQueryArgs(getState()); for (const imageDTO of imageDTOs) { if (imageDTO.is_intermediate) { return; } - const boardId = imageDTO.board_id ?? 'none'; + const board_id = imageDTO.board_id ?? 'none'; // update the total images for the board - boardTotalAdditions[boardId] = (boardTotalAdditions[boardId] || 0) + 1; + boardTotalAdditions[board_id] = (boardTotalAdditions[board_id] || 0) + 1; // invalidate the board tag - boardTagIdsToInvalidate.add(boardId); + boardTagIdsToInvalidate.add(board_id); // invalidate the image list tag imageListTagIdsToInvalidate.add( - getListImagesUrl({ - board_id: boardId, + stableHash({ + ...listImagesArg, categories: getCategories(imageDTO), + board_id, + offset: 0, }) ); } @@ -86,48 +96,79 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi })); dispatch(imagesApi.util.invalidateTags([...boardTags, ...imageListTags])); - // Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list. + const autoSwitch = selectAutoSwitch(getState()); + if (!autoSwitch) { + return; + } + + // Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list. const lastImageDTO = imageDTOs.at(-1); if (!lastImageDTO) { return; } - const { image_name, board_id } = lastImageDTO; + const { image_name } = lastImageDTO; + const board_id = lastImageDTO.board_id ?? 'none'; - const { shouldAutoSwitch, selectedBoardId, galleryView, offset } = getState().gallery; + /** + * Auto-switch needs a bit of care to avoid race conditions - we need to invalidate the appropriate image list + * query cache, and only after it has loaded, select the new image. + */ + const queryArgs = { + ...listImagesArg, + categories: getCategories(lastImageDTO), + board_id, + offset: 0, + }; - // If auto-switch is enabled, select the new image - if (shouldAutoSwitch) { - // If the image is from a different board, switch to that board - this will also select the image - if (board_id && board_id !== selectedBoardId) { - dispatch( - boardIdSelected({ - boardId: board_id, - selectedImageName: image_name, - }) - ); - } else if (!board_id && selectedBoardId !== 'none') { - dispatch( - boardIdSelected({ - boardId: 'none', - selectedImageName: image_name, - }) - ); - } else { - // Else just select the image, no need to switch boards - dispatch(imageSelected(lastImageDTO.image_name)); + dispatch( + addAppListener({ + predicate: (action) => { + if (!imagesApi.endpoints.listImages.matchFulfilled(action)) { + return false; + } - if (galleryView !== 'images') { - // We also need to update the gallery view to images. This also updates the offset. - dispatch(galleryViewChanged('images')); - } else if (offset > 0) { - // If we are not at the start of the gallery, reset the offset. - dispatch(offsetChanged({ offset: 0 })); - } - } - } + if (stableHash(action.meta.arg.originalArgs) !== stableHash(queryArgs)) { + return false; + } + + return true; + }, + effect: (_action, { getState, dispatch, unsubscribe }) => { + // This is a one-time listener - we always unsubscribe after the first match + unsubscribe(); + + // Auto-switch may have been disabled while we were waiting for the query to resolve - bail if so + const autoSwitch = selectAutoSwitch(getState()); + if (!autoSwitch) { + return; + } + + const selectedBoardId = selectSelectedBoardId(getState()); + + // If the image is from a different board, switch to that board & select the image - otherwise just select the + // image. This implicitly changes the view to 'images' if it was not already. + if (board_id !== selectedBoardId) { + dispatch( + boardIdSelected({ + boardId: board_id, + selectedImageName: image_name, + }) + ); + } else { + // Ensure we are on the 'images' gallery view - that's where this image will be displayed + const galleryView = selectGalleryView(getState()); + if (galleryView !== 'images') { + dispatch(galleryViewChanged('images')); + } + // Else just select the image, no need to switch boards + dispatch(imageSelected(lastImageDTO.image_name)); + } + }, + }) + ); }; const getResultImageDTOs = async (data: S['InvocationCompleteEvent']): Promise => { @@ -151,69 +192,23 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi return imageDTOs; }; - const handleOriginWorkflows = async (data: S['InvocationCompleteEvent']) => { - const { result, invocation_source_id } = data; - - const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]); - if (nes) { - nes.status = zNodeStatus.enum.COMPLETED; - if (nes.progress !== null) { - nes.progress = 1; - } - nes.outputs.push(result); - upsertExecutionState(nes.nodeId, nes); - } - - await addImagesToGallery(data); - }; - - const handleOriginCanvas = async (data: S['InvocationCompleteEvent']) => { - if (!isCanvasOutputEvent(data)) { - return; - } - - await addImagesToGallery(data); - - // // We expect only a single image in the canvas output - // const imageDTO = (await getResultImageDTOs(data))[0]; - - // if (!imageDTO) { - // return; - // } - - // flushSync(() => { - // dispatch( - // stagingAreaImageStaged({ - // stagingAreaImage: { type: 'staged', sessionId: data.session_id, imageDTO, offsetX: 0, offsetY: 0 }, - // }) - // ); - // }); - - // const progressData = $progressImages.get()[data.session_id]; - // if (progressData) { - // $progressImages.setKey(data.session_id, { ...progressData, isFinished: true, resultImage: imageDTO }); - // } else { - // $progressImages.setKey(data.session_id, { sessionId: data.session_id, isFinished: true, resultImage: imageDTO }); - // } - - // $lastCanvasProgressImage.set(null); - }; - - const handleOriginOther = async (data: S['InvocationCompleteEvent']) => { - await addImagesToGallery(data); - }; - return async (data: S['InvocationCompleteEvent']) => { log.debug({ data } as JsonObject, `Invocation complete (${data.invocation.type}, ${data.invocation_source_id})`); - if (data.origin === 'workflows') { - await handleOriginWorkflows(data); - } else if (data.origin === 'canvas') { - await handleOriginCanvas(data); - } else { - await handleOriginOther(data); + const nodeExecutionState = $nodeExecutionStates.get()[data.invocation_source_id]; + + if (nodeExecutionState) { + const _nodeExecutionState = deepClone(nodeExecutionState); + _nodeExecutionState.status = zNodeStatus.enum.COMPLETED; + if (_nodeExecutionState.progress !== null) { + _nodeExecutionState.progress = 1; + } + _nodeExecutionState.outputs.push(data.result); + upsertExecutionState(_nodeExecutionState.nodeId, _nodeExecutionState); } + await addImagesToGallery(data); + $lastProgressEvent.set(null); }; }; From 98368b06651ef73f9984b22e9c435cb58b5a4619 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:02:13 +1000 Subject: [PATCH 167/210] feat(ui): restore gallery hotkeys (except delete) --- .../web/src/common/hooks/useGlobalHotkeys.ts | 16 ++ .../components/ImageGrid/GalleryImageGrid.tsx | 204 ------------- .../ImageGrid/GalleryPagination.tsx | 82 ------ .../components/ImageGrid/GallerySearch.tsx | 6 +- .../ImageGrid/GallerySelectionCountTag.tsx | 8 +- .../gallery/components/ImageGrid/JumpTo.tsx | 104 ------- .../ImageViewer/CurrentImagePreview.tsx | 3 +- .../gallery/components/NewGallery.tsx | 172 ++++++++--- .../components/NextPrevImageButtons.tsx | 71 +++-- .../gallery/hooks/useGalleryHotkeys.ts | 203 ------------- .../gallery/hooks/useGalleryImages.ts | 15 - .../gallery/hooks/useGalleryNavigation.ts | 270 ------------------ .../gallery/hooks/useGalleryPagination.ts | 144 ---------- .../nodes/CurrentImage/CurrentImageNode.tsx | 3 +- 14 files changed, 184 insertions(+), 1117 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/hooks/useGalleryHotkeys.ts delete mode 100644 invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts delete mode 100644 invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts delete mode 100644 invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index 9ff3141613..4cd4cb535c 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -117,4 +117,20 @@ export const useGlobalHotkeys = () => { }, dependencies: [dispatch, isModelManagerEnabled], }); + + // TODO: implement delete - needs to handle gallery focus, which has changed w/ dockview + // useRegisteredHotkeys({ + // id: 'deleteSelection', + // category: 'gallery', + // callback: () => { + // if (!selection.length) { + // return; + // } + // deleteImageModal.delete(selection); + // }, + // options: { + // enabled: (isGalleryFocused || isImageViewerFocused) && isDeleteEnabledByTab && !isWorkflowsFocused, + // }, + // dependencies: [isWorkflowsFocused, isDeleteEnabledByTab, selection, isWorkflowsFocused], + // }); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx deleted file mode 100644 index 76ace602b6..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { Box, Flex, Grid } from '@invoke-ai/ui-library'; -import { EMPTY_ARRAY } from 'app/store/constants'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { GallerySelectionCountTag } from 'features/gallery/components/ImageGrid/GallerySelectionCountTag'; -import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys'; -import { - selectGalleryImageMinimumWidth, - selectGalleryLimit, - selectListImagesQueryArgs, -} from 'features/gallery/store/gallerySelectors'; -import { limitChanged } from 'features/gallery/store/gallerySlice'; -import { debounce } from 'lodash-es'; -import { memo, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiImageBold, PiWarningCircleBold } from 'react-icons/pi'; -import { useListImagesQuery } from 'services/api/endpoints/images'; - -import { GALLERY_GRID_CLASS_NAME } from './constants'; -import { GALLERY_IMAGE_CONTAINER_CLASS_NAME, GalleryImage } from './GalleryImage'; - -const GalleryImageGrid = () => { - useGalleryHotkeys(); - const { t } = useTranslation(); - const queryArgs = useAppSelector(selectListImagesQueryArgs); - const { hasImages, isLoading, isError } = useListImagesQuery(queryArgs, { - selectFromResult: ({ data, isLoading, isSuccess, isError }) => ({ - hasImages: data && data.items.length > 0, - isLoading, - isSuccess, - isError, - }), - }); - - if (isError) { - return ( - - - - ); - } - - if (isLoading) { - return ( - - - - ); - } - - if (!hasImages) { - return ( - - - - ); - } - - return ; -}; - -export default memo(GalleryImageGrid); - -const GalleryImageGridContent = memo(() => { - const dispatch = useAppDispatch(); - const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); - const limit = useAppSelector(selectGalleryLimit); - - // Use a callback ref to get reactivity on the container element because it is conditionally rendered - const [container, containerRef] = useState(null); - - const calculateNewLimit = useMemo(() => { - // Debounce this to not thrash the API - return debounce(() => { - if (!container) { - // Container not rendered yet - return; - } - // Managing refs for dynamically rendered components is a bit tedious: - // - https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback - // As a easy workaround, we can just grab the first gallery image element directly. - const imageEl = document.querySelector(`.${GALLERY_IMAGE_CONTAINER_CLASS_NAME}`); - if (!imageEl) { - // No images in gallery? - return; - } - - const gridEl = document.querySelector(`.${GALLERY_GRID_CLASS_NAME}`); - - if (!gridEl) { - return; - } - - const imageRect = imageEl.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - // We need to account for the gap between images - const gridElStyle = window.getComputedStyle(gridEl); - const gap = parseFloat(gridElStyle.gap); - - if (!imageRect.width || !imageRect.height || !containerRect.width || !containerRect.height) { - // Gallery is too small to fit images or not rendered yet - return; - } - - let imagesPerColumn = 0; - let spaceUsed = 0; - - // Floating point precision can cause imagesPerColumn to be 1 too small. Adding 1px to the container size fixes - // this. Because the minimum image size is without the possibility of overshooting. - while (spaceUsed + imageRect.height <= containerRect.height + 1) { - imagesPerColumn++; // Increment the number of images - spaceUsed += imageRect.height; // Add image size to the used space - if (spaceUsed + gap <= containerRect.height) { - spaceUsed += gap; // Add gap size to the used space after each image except after the last image - } - } - - let imagesPerRow = 0; - spaceUsed = 0; - - // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes - // this, without the possibility of accidentally adding an extra column. - while (spaceUsed + imageRect.width <= containerRect.width + 1) { - imagesPerRow++; // Increment the number of images - spaceUsed += imageRect.width; // Add image size to the used space - if (spaceUsed + gap <= containerRect.width) { - spaceUsed += gap; // Add gap size to the used space after each image except after the last image - } - } - - // Always load at least 1 row of images - const newLimit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn); - - if (limit === 0 || limit === newLimit) { - return; - } - dispatch(limitChanged(newLimit)); - }, 300); - }, [container, dispatch, limit]); - - useEffect(() => { - // We want to recalculate the limit when image size changes - calculateNewLimit(); - }, [calculateNewLimit, galleryImageMinimumWidth]); - - useEffect(() => { - if (!container) { - return; - } - - const resizeObserver = new ResizeObserver(calculateNewLimit); - resizeObserver.observe(container); - - // First render - calculateNewLimit(); - - return () => { - resizeObserver.disconnect(); - }; - }, [calculateNewLimit, container, dispatch]); - - return ( - - - - - - - - - ); -}); - -GalleryImageGridContent.displayName = 'GalleryImageGridContent'; - -const GalleryImageGridImages = memo(() => { - const queryArgs = useAppSelector(selectListImagesQueryArgs); - const { imageDTOs } = useListImagesQuery(queryArgs, { - selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }), - }); - return ( - <> - {imageDTOs.map((imageDTO) => ( - - ))} - - ); -}); -GalleryImageGridImages.displayName = 'GalleryImageGridImages'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx deleted file mode 100644 index eaca653b17..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; -import { ELLIPSIS, useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; -import { memo, useCallback } from 'react'; -import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; - -import { JumpTo } from './JumpTo'; - -export const GalleryPagination = memo(() => { - const { goPrev, goNext, isPrevEnabled, isNextEnabled, pageButtons, goToPage, currentPage, total } = - useGalleryPagination(); - - const onClickPrev = useCallback(() => { - goPrev(); - }, [goPrev]); - - const onClickNext = useCallback(() => { - goNext(); - }, [goNext]); - - if (!total) { - return null; - } - - return ( - - } - onClick={onClickPrev} - isDisabled={!isPrevEnabled} - variant="ghost" - /> - - {pageButtons.map((page, i) => ( - - ))} - - } - onClick={onClickNext} - isDisabled={!isNextEnabled} - variant="ghost" - /> - - - ); -}); - -GalleryPagination.displayName = 'GalleryPagination'; - -type PageButtonProps = { - page: number | typeof ELLIPSIS; - currentPage: number; - goToPage: (page: number) => void; -}; - -const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => { - const onClick = useCallback(() => { - if (page === ELLIPSIS) { - return; - } - goToPage(page - 1); - }, [goToPage, page]); - - if (page === ELLIPSIS) { - return ( - - ); - } - return ( - - ); -}); - -PageButton.displayName = 'PageButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx index 7159236e26..ce18285449 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx @@ -1,10 +1,9 @@ import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library'; -import { useDebouncedImageCollectionQueryArgs } from 'features/gallery/components/NewGallery'; +import { useGalleryImageNames } from 'features/gallery/components/NewGallery'; import type { ChangeEvent, KeyboardEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; -import { useGetImageCollectionCountsQuery } from 'services/api/endpoints/images'; type Props = { searchTerm: string; @@ -14,8 +13,7 @@ type Props = { export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { const { t } = useTranslation(); - const queryArgs = useDebouncedImageCollectionQueryArgs(); - const { isFetching } = useGetImageCollectionCountsQuery(queryArgs); + const { isFetching } = useGalleryImageNames(); const handleChangeInput = useCallback( (e: ChangeEvent) => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index e76936c090..5508ad1156 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -1,7 +1,7 @@ import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; +import { useGalleryImageNames } from 'features/gallery/components/NewGallery'; import { selectFirstSelectedImage, selectSelection, @@ -15,12 +15,12 @@ import { useTranslation } from 'react-i18next'; export const GallerySelectionCountTag = memo(() => { const dispatch = useAppDispatch(); const selection = useAppSelector(selectSelection); - const { imageDTOs } = useGalleryImages(); + const { imageNames } = useGalleryImageNames(); const isGalleryFocused = useIsRegionFocused('gallery'); const onSelectPage = useCallback(() => { - dispatch(selectionChanged([...selection, ...imageDTOs.map(({ image_name }) => image_name)])); - }, [dispatch, selection, imageDTOs]); + dispatch(selectionChanged([...imageNames])); + }, [dispatch, imageNames]); useRegisteredHotkeys({ id: 'selectAllOnPage', diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx deleted file mode 100644 index cf75502300..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { - Button, - CompositeNumberInput, - Flex, - FormControl, - Popover, - PopoverArrow, - PopoverBody, - PopoverContent, - PopoverTrigger, - useDisclosure, -} from '@invoke-ai/ui-library'; -import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; -import { useTranslation } from 'react-i18next'; - -export const JumpTo = memo(() => { - const { t } = useTranslation(); - const disclosure = useDisclosure(); - - return ( - - - - - - - - - - - - ); -}); - -JumpTo.displayName = 'JumpTo'; - -const JumpToContent = memo(({ disclosure }: { disclosure: ReturnType }) => { - const { t } = useTranslation(); - const { goToPage, currentPage, pages } = useGalleryPagination(); - const [newPage, setNewPage] = useState(currentPage); - const ref = useRef(null); - - const onChangeJumpTo = useCallback((v: number) => { - setNewPage(v - 1); - }, []); - - const onClickGo = useCallback(() => { - goToPage(newPage); - disclosure.onClose(); - }, [goToPage, newPage, disclosure]); - - useHotkeys( - 'enter', - () => { - onClickGo(); - }, - { enabled: disclosure.isOpen, enableOnFormTags: ['input'] }, - [disclosure.isOpen, onClickGo] - ); - - useHotkeys( - 'esc', - () => { - setNewPage(currentPage); - disclosure.onClose(); - }, - { enabled: disclosure.isOpen, enableOnFormTags: ['input'] }, - [disclosure.isOpen, disclosure.onClose] - ); - - useEffect(() => { - setTimeout(() => { - const input = ref.current?.querySelector('input'); - input?.focus(); - input?.select(); - }, 0); - setNewPage(currentPage); - }, [currentPage]); - - return ( - - - - - - - ); -}); -JumpToContent.displayName = 'JumpToContent'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 4e57a9b035..faa0bd91ad 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -4,6 +4,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; +import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; @@ -114,7 +115,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu left={0} pointerEvents="none" > - {/* */} + )} diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 36b33fa406..eea3395dd8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -2,15 +2,17 @@ import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from ' import { createSelector } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import { EMPTY_ARRAY } from 'app/store/constants'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { selectGalleryImageMinimumWidth, + selectImageToCompare, selectLastSelectedImage, selectListImagesQueryArgs, } from 'features/gallery/store/gallerySelectors'; -import { selectionChanged } from 'features/gallery/store/gallerySlice'; +import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import type { MutableRefObject } from 'react'; +import type { MutableRefObject, RefObject } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GridComponents, @@ -42,7 +44,7 @@ type GridContext = { imageNames: string[]; }; -export const useDebouncedImageCollectionQueryArgs = () => { +export const useDebouncedListImagesQueryArgs = () => { const _galleryQueryArgs = useAppSelector(selectListImagesQueryArgs); const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY); return queryArgs; @@ -93,7 +95,7 @@ const ImageAtPosition = memo( ImageAtPosition.displayName = 'ImageAtPosition'; // Memoized compute key function using image names -const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { +const computeItemKey: GridComputeItemKey = (_index, imageName, { queryArgs }) => { return `${JSON.stringify(queryArgs)}-${imageName}`; }; @@ -202,7 +204,7 @@ const scrollIntoView = ( return; }; -const getImageIndex = (imageName: string | undefined, imageNames: string[]) => { +const getImageIndex = (imageName: string | undefined | null, imageNames: string[]) => { if (!imageName || imageNames.length === 0) { return 0; } @@ -216,30 +218,30 @@ const useKeyboardNavigation = ( virtuosoRef: React.RefObject, rootRef: React.RefObject ) => { - const dispatch = useAppDispatch(); - const lastSelectedImage = useAppSelector(selectLastSelectedImage); + const { dispatch, getState } = useAppStore(); const handleKeyDown = useCallback( (event: KeyboardEvent) => { - const rootEl = rootRef.current; - const virtuosoGridHandle = virtuosoRef.current; - if (!rootEl || !virtuosoGridHandle) { - return; - } - if (imageNames.length === 0) { - return; - } - // Only handle arrow keys if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return; } - // Don't interfere if user is typing in an input if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; } + const rootEl = rootRef.current; + const virtuosoGridHandle = virtuosoRef.current; + + if (!rootEl || !virtuosoGridHandle) { + return; + } + + if (imageNames.length === 0) { + return; + } + const imagesPerRow = getImagesPerRow(rootEl); if (imagesPerRow === 0) { @@ -249,7 +251,14 @@ const useKeyboardNavigation = ( event.preventDefault(); - const currentIndex = getImageIndex(lastSelectedImage, imageNames); + const imageName = event.altKey + ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, + // we start from the last selected image + (selectImageToCompare(getState()) ?? selectLastSelectedImage(getState())) + : selectLastSelectedImage(getState()); + + const currentIndex = getImageIndex(imageName, imageNames); + let newIndex = currentIndex; switch (event.key) { @@ -290,19 +299,80 @@ const useKeyboardNavigation = ( if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) { const newImageName = imageNames[newIndex]; if (newImageName) { - dispatch(selectionChanged([newImageName])); + if (event.altKey) { + dispatch(imageToCompareChanged(newImageName)); + } else { + dispatch(selectionChanged([newImageName])); + } } } }, - [rootRef, virtuosoRef, imageNames, lastSelectedImage, dispatch] + [rootRef, virtuosoRef, imageNames, getState, dispatch] ); - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [handleKeyDown]); + useRegisteredHotkeys({ + id: 'galleryNavLeft', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavRight', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavUp', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavDown', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavLeftAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavRightAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavUpAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavDownAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); }; const useKeepSelectedImageInView = ( @@ -330,28 +400,21 @@ const useKeepSelectedImageInView = ( }; const getImageNamesQueryOptions = { - selectFromResult: ({ data, isLoading }) => ({ + selectFromResult: ({ data, isLoading, isFetching }) => ({ imageNames: data ?? EMPTY_ARRAY, isLoading, + isFetching, }), } satisfies Parameters[1]; -// Main gallery component -export const NewGallery = memo(() => { - const queryArgs = useDebouncedImageCollectionQueryArgs(); - const virtuosoRef = useRef(null); - const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); - const rootRef = useRef(null); +export const useGalleryImageNames = () => { + const queryArgs = useDebouncedListImagesQueryArgs(); + const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); + return { imageNames, isLoading, isFetching, queryArgs }; +}; - // Get the ordered list of image names - this is our primary data source for virtualization - const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); - - useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); - - // Enable keyboard navigation - useKeyboardNavigation(imageNames, virtuosoRef, rootRef); - - const [scroller, setScroller] = useState(null); +const useScrollableGallery = (rootRef: RefObject) => { + const [scroller, scrollerRef] = useState(null); const [initialize, osInstance] = useOverlayScrollbars({ defer: true, events: { @@ -379,9 +442,25 @@ export const NewGallery = memo(() => { return () => { osInstance()?.destroy(); }; - }, [scroller, initialize, osInstance]); + }, [scroller, initialize, osInstance, rootRef]); - // Handle range changes - RTK Query will automatically cache and manage loading + return scrollerRef; +}; + +// Main gallery component +export const NewGallery = memo(() => { + const virtuosoRef = useRef(null); + const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); + const rootRef = useRef(null); + + // Get the ordered list of image names - this is our primary data source for virtualization + const { queryArgs, imageNames, isLoading } = useGalleryImageNames(); + + useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); + useKeyboardNavigation(imageNames, virtuosoRef, rootRef); + const scrollerRef = useScrollableGallery(rootRef); + + // We have to keep track of the visible range for keep-selected-image-in-view functionality const handleRangeChanged = useCallback((range: ListRange) => { rangeRef.current = range; }, []); @@ -422,14 +501,13 @@ export const NewGallery = memo(() => { ref={virtuosoRef} context={context} - totalCount={imageNames.length} data={imageNames} increaseViewportBy={VIEWPORT_BUFFER} itemContent={itemContent} computeItemKey={computeItemKey} components={components} style={style} - scrollerRef={setScroller} + scrollerRef={scrollerRef} scrollSeekConfiguration={scrollSeekConfiguration} rangeChanged={handleRangeChanged} /> diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index d2187d4f87..371d8800e2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -1,58 +1,53 @@ import type { ChakraProps } from '@invoke-ai/ui-library'; import { Box, IconButton } from '@invoke-ai/ui-library'; -import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { clamp } from 'lodash-es'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; +import { useGalleryImageNames } from './NewGallery'; + const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const lastSelectedImage = useAppSelector(selectLastSelectedImage); + const { imageNames, isFetching } = useGalleryImageNames(); - const { isFetching } = useGalleryImages().queryResult; - - const shouldShowLeftArrow = useMemo(() => { - if (!isOnFirstImageOfView) { - return true; - } - if (isPrevEnabled) { - return true; - } - return false; - }, [isOnFirstImageOfView, isPrevEnabled]); + const isOnFirstImage = useMemo( + () => (lastSelectedImage ? imageNames.at(0) === lastSelectedImage : false), + [imageNames, lastSelectedImage] + ); + const isOnLastImage = useMemo( + () => (lastSelectedImage ? imageNames.at(-1) === lastSelectedImage : false), + [imageNames, lastSelectedImage] + ); const onClickLeftArrow = useCallback(() => { - if (isOnFirstImageOfView) { - if (isPrevEnabled && !isFetching) { - goPrev('arrow'); - } - } else { - prevImage(); + const targetIndex = lastSelectedImage ? imageNames.findIndex((n) => n === lastSelectedImage) - 1 : 0; + const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); + const n = imageNames.at(clampedIndex); + if (!n) { + return; } - }, [goPrev, isFetching, isOnFirstImageOfView, isPrevEnabled, prevImage]); - - const shouldShowRightArrow = useMemo(() => { - if (!isOnLastImageOfView) { - return true; - } - if (isNextEnabled) { - return true; - } - return false; - }, [isNextEnabled, isOnLastImageOfView]); + dispatch(imageSelected(n)); + }, [dispatch, imageNames, lastSelectedImage]); const onClickRightArrow = useCallback(() => { - if (isOnLastImageOfView) { - if (isNextEnabled && !isFetching) { - goNext('arrow'); - } - } else { - nextImage(); + const targetIndex = lastSelectedImage ? imageNames.findIndex((n) => n === lastSelectedImage) + 1 : 0; + const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); + const n = imageNames.at(clampedIndex); + if (!n) { + return; } - }, [goNext, isFetching, isNextEnabled, isOnLastImageOfView, nextImage]); + dispatch(imageSelected(n)); + }, [dispatch, imageNames, lastSelectedImage]); return ( - {shouldShowLeftArrow && ( + {!isOnFirstImage && ( )} - {shouldShowRightArrow && ( + {!isOnLastImage && ( { - const selection = useAppSelector((s) => s.gallery.selection); - const queryArgs = useAppSelector(selectListImagesQueryArgs); - const queryResult = useListImagesQuery(queryArgs); - const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel); - const appTab = useAppSelector(selectActiveTab); - const isWorkflowsFocused = useIsRegionFocused('workflows'); - const isGalleryFocused = useIsRegionFocused('gallery'); - const isImageViewerFocused = useIsRegionFocused('viewer'); - const deleteImageModal = useDeleteImageModalApi(); - - // When we are on the canvas tab, we need to disable the delete hotkey when the user is focused on the layers tab in - // the right hand panel, because the same hotkey is used to delete layers. - const isDeleteEnabledByTab = useMemo(() => { - if (appTab !== 'canvas') { - return true; - } - return canvasRightPanelTab === 'gallery'; - }, [appTab, canvasRightPanelTab]); - - useRegisteredHotkeys({ - id: 'galleryNavLeft', - category: 'gallery', - callback: (e) => { - // Skip the hotkey if the user is focused on a tab element - the arrow keys are used to navigate between tabs. - if (e.target instanceof HTMLElement && e.target.getAttribute('role') === 'tab') { - return; - } - if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) { - goPrev('arrow'); - return; - } - handleLeftImage(false); - }, - options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused }, - dependencies: [ - handleLeftImage, - isOnFirstImageOfView, - goPrev, - isPrevEnabled, - queryResult.isFetching, - isGalleryFocused, - isImageViewerFocused, - ], - }); - - useRegisteredHotkeys({ - id: 'galleryNavRight', - category: 'gallery', - callback: (e) => { - // Skip the hotkey if the user is focused on a tab element - the arrow keys are used to navigate between tabs. - if (e.target instanceof HTMLElement && e.target.getAttribute('role') === 'tab') { - return; - } - if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) { - goNext('arrow'); - return; - } - if (!isOnLastImageOfView) { - handleRightImage(false); - } - }, - options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused }, - dependencies: [ - isOnLastImageOfView, - goNext, - isNextEnabled, - queryResult.isFetching, - handleRightImage, - isGalleryFocused, - isImageViewerFocused, - ], - }); - - useRegisteredHotkeys({ - id: 'galleryNavUp', - category: 'gallery', - callback: () => { - if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) { - goPrev('arrow'); - return; - } - handleUpImage(false); - }, - options: { preventDefault: true, enabled: isGalleryFocused }, - dependencies: [handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, isGalleryFocused], - }); - - useRegisteredHotkeys({ - id: 'galleryNavDown', - category: 'gallery', - callback: () => { - if (isOnLastRow && isNextEnabled && !queryResult.isFetching) { - goNext('arrow'); - return; - } - handleDownImage(false); - }, - options: { preventDefault: true, enabled: isGalleryFocused }, - dependencies: [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, isGalleryFocused], - }); - - useRegisteredHotkeys({ - id: 'galleryNavLeftAlt', - category: 'gallery', - callback: () => { - if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) { - goPrev('alt+arrow'); - return; - } - handleLeftImage(true); - }, - options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused }, - dependencies: [ - handleLeftImage, - isOnFirstImageOfView, - goPrev, - isPrevEnabled, - queryResult.isFetching, - isGalleryFocused, - isImageViewerFocused, - ], - }); - - useRegisteredHotkeys({ - id: 'galleryNavRightAlt', - category: 'gallery', - callback: () => { - if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) { - goNext('alt+arrow'); - return; - } - if (!isOnLastImageOfView) { - handleRightImage(true); - } - }, - options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused }, - dependencies: [ - isOnLastImageOfView, - goNext, - isNextEnabled, - queryResult.isFetching, - handleRightImage, - isGalleryFocused, - isImageViewerFocused, - ], - }); - - useRegisteredHotkeys({ - id: 'galleryNavUpAlt', - category: 'gallery', - callback: () => { - if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) { - goPrev('alt+arrow'); - return; - } - handleUpImage(true); - }, - options: { preventDefault: true, enabled: isGalleryFocused }, - dependencies: [handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, isGalleryFocused], - }); - - useRegisteredHotkeys({ - id: 'galleryNavDownAlt', - category: 'gallery', - callback: () => { - if (isOnLastRow && isNextEnabled && !queryResult.isFetching) { - goNext('alt+arrow'); - return; - } - handleDownImage(true); - }, - options: { preventDefault: true, enabled: isGalleryFocused }, - dependencies: [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, isGalleryFocused], - }); - - useRegisteredHotkeys({ - id: 'deleteSelection', - category: 'gallery', - callback: () => { - if (!selection.length) { - return; - } - deleteImageModal.delete(selection); - }, - options: { - enabled: (isGalleryFocused || isImageViewerFocused) && isDeleteEnabledByTab && !isWorkflowsFocused, - }, - dependencies: [isWorkflowsFocused, isDeleteEnabledByTab, selection, isWorkflowsFocused], - }); -}; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts deleted file mode 100644 index 6e878f9c26..0000000000 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryImages.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { EMPTY_ARRAY } from 'app/store/constants'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { useMemo } from 'react'; -import { useListImagesQuery } from 'services/api/endpoints/images'; - -export const useGalleryImages = () => { - const queryArgs = useAppSelector(selectListImagesQueryArgs); - const queryResult = useListImagesQuery(queryArgs); - const imageDTOs = useMemo(() => queryResult.data?.items ?? EMPTY_ARRAY, [queryResult.data]); - return { - imageDTOs, - queryResult, - }; -}; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts deleted file mode 100644 index a25e96f0cc..0000000000 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { useAltModifier } from '@invoke-ai/ui-library'; -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { GALLERY_GRID_CLASS_NAME } from 'features/gallery/components/ImageGrid/constants'; -import { GALLERY_IMAGE_CONTAINER_CLASS_NAME } from 'features/gallery/components/ImageGrid/GalleryImage'; -import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; -import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types'; -import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; -import { selectImageToCompare, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; -import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; -import { getIsVisible } from 'features/gallery/util/getIsVisible'; -import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign'; -import { clamp } from 'lodash-es'; -import { useCallback, useMemo } from 'react'; -import type { ImageDTO } from 'services/api/types'; - -/** - * This hook is used to navigate the gallery using the arrow keys. - * - * The gallery is rendered as a grid. In order to navigate the grid, - * we need to know how many images are in each row and whether or not - * an image is visible in the gallery. - * - * We use direct DOM query selectors to check if an image is visible - * to avoid having to track a ref for each image. - */ - -/** - * Gets the number of images per row in the gallery by grabbing their DOM elements. - */ -const getImagesPerRow = (): number => { - const imageEl = document.querySelector(`.${GALLERY_IMAGE_CONTAINER_CLASS_NAME}`); - const gridEl = document.querySelector(`.${GALLERY_GRID_CLASS_NAME}`); - - if (!imageEl || !gridEl) { - return 0; - } - const container = gridEl.parentElement; - if (!container) { - return 0; - } - - const imageRect = imageEl.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - // We need to account for the gap between images - const gridElStyle = window.getComputedStyle(gridEl); - const gap = parseFloat(gridElStyle.gap); - - if (!imageRect.width || !imageRect.height || !containerRect.width || !containerRect.height) { - // Gallery is too small to fit images or not rendered yet - return 0; - } - - let imagesPerRow = 0; - let spaceUsed = 0; - - // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes - // this, without the possibility of accidentally adding an extra column. - while (spaceUsed + imageRect.width <= containerRect.width + 1) { - imagesPerRow++; // Increment the number of images - spaceUsed += imageRect.width; // Add image size to the used space - if (spaceUsed + gap <= containerRect.width) { - spaceUsed += gap; // Add gap size to the used space after each image except after the last image - } - } - - return imagesPerRow; -}; - -/** - * Scrolls to the image with the given name. - * If the image is not fully visible, it will not be scrolled to. - * @param imageName The image name to scroll to. - * @param index The index of the image in the gallery. - */ -const scrollToImage = (imageName: string, index: number) => { - const virtuosoContext = virtuosoGridRefs.get(); - const range = virtuosoContext.virtuosoRangeRef?.current; - const root = virtuosoContext.rootRef?.current; - const virtuoso = virtuosoContext.virtuosoRef?.current; - - if (!range || !virtuoso || !root) { - return; - } - - const imageElement = document.querySelector(`[data-testid="${getGalleryImageDataTestId(imageName)}"]`); - const itemRect = imageElement?.getBoundingClientRect(); - const rootRect = root.getBoundingClientRect(); - if (!itemRect || !getIsVisible(itemRect, rootRect)) { - virtuoso.scrollToIndex({ - index, - align: getScrollToIndexAlign(index, range), - }); - } -}; - -// Utilities to get the image to the left, right, up, or down of the current image. - -const getLeftImage = (images: ImageDTO[], currentIndex: number) => { - const index = clamp(currentIndex - 1, 0, images.length - 1); - const image = images[index]; - return { index, image }; -}; - -const getRightImage = (images: ImageDTO[], currentIndex: number) => { - const index = clamp(currentIndex + 1, 0, images.length - 1); - const image = images[index]; - return { index, image }; -}; - -const getUpImage = (images: ImageDTO[], currentIndex: number) => { - const imagesPerRow = getImagesPerRow(); - // If we are on the first row, we want to stay on the first row, not go to first image - const isOnFirstRow = currentIndex < imagesPerRow; - const index = isOnFirstRow ? currentIndex : clamp(currentIndex - imagesPerRow, 0, images.length - 1); - const image = images[index]; - return { index, image }; -}; - -const getDownImage = (images: ImageDTO[], currentIndex: number) => { - const imagesPerRow = getImagesPerRow(); - // If there are no images below the current image, we want to stay where we are - const areImagesBelow = currentIndex < images.length - imagesPerRow; - const index = areImagesBelow ? clamp(currentIndex + imagesPerRow, 0, images.length - 1) : currentIndex; - const image = images[index]; - return { index, image }; -}; - -const getImageFuncs = { - left: getLeftImage, - right: getRightImage, - up: getUpImage, - down: getDownImage, -}; - -type UseGalleryNavigationReturn = { - handleLeftImage: (alt?: boolean) => void; - handleRightImage: (alt?: boolean) => void; - handleUpImage: (alt?: boolean) => void; - handleDownImage: (alt?: boolean) => void; - prevImage: () => void; - nextImage: () => void; - isOnFirstImage: boolean; - isOnLastImage: boolean; - isOnFirstRow: boolean; - isOnLastRow: boolean; - isOnFirstImageOfView: boolean; - isOnLastImageOfView: boolean; -}; - -/** - * Provides access to the gallery navigation via arrow keys. - * Also provides information about the current image's position in the gallery, - * useful for determining whether to load more images or display navigation - * buttons. - */ -export const useGalleryNavigation = (): UseGalleryNavigationReturn => { - const dispatch = useAppDispatch(); - const alt = useAltModifier(); - const selectImage = useMemo( - () => - createSelector(selectLastSelectedImage, selectImageToCompare, (lastSelectedImage, imageToCompare) => { - if (alt) { - return imageToCompare ?? lastSelectedImage; - } else { - return lastSelectedImage; - } - }), - [alt] - ); - const lastSelectedImage = useAppSelector(selectImage); - const { imageDTOs } = useGalleryImages(); - const loadedImagesCount = useMemo(() => imageDTOs.length, [imageDTOs.length]); - - const lastSelectedImageIndex = useMemo(() => { - if (imageDTOs.length === 0 || !lastSelectedImage) { - return 0; - } - return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage); - }, [imageDTOs, lastSelectedImage]); - - const handleNavigation = useCallback( - (direction: 'left' | 'right' | 'up' | 'down', alt?: boolean) => { - const { index, image } = getImageFuncs[direction](imageDTOs, lastSelectedImageIndex); - if (!image || index === lastSelectedImageIndex) { - return; - } - if (alt) { - dispatch(imageToCompareChanged(image.image_name)); - } else { - dispatch(imageSelected(image.image_name)); - } - scrollToImage(image.image_name, index); - }, - [imageDTOs, lastSelectedImageIndex, dispatch] - ); - - const isOnFirstImage = useMemo(() => lastSelectedImageIndex === 0, [lastSelectedImageIndex]); - - const isOnLastImage = useMemo( - () => lastSelectedImageIndex === loadedImagesCount - 1, - [lastSelectedImageIndex, loadedImagesCount] - ); - - const isOnFirstRow = useMemo(() => lastSelectedImageIndex < getImagesPerRow(), [lastSelectedImageIndex]); - const isOnLastRow = useMemo( - () => lastSelectedImageIndex >= loadedImagesCount - getImagesPerRow(), - [lastSelectedImageIndex, loadedImagesCount] - ); - - const isOnFirstImageOfView = useMemo(() => { - return lastSelectedImageIndex === 0; - }, [lastSelectedImageIndex]); - - const isOnLastImageOfView = useMemo(() => { - return lastSelectedImageIndex === loadedImagesCount - 1; - }, [lastSelectedImageIndex, loadedImagesCount]); - - const handleLeftImage = useCallback( - (alt?: boolean) => { - handleNavigation('left', alt); - }, - [handleNavigation] - ); - - const handleRightImage = useCallback( - (alt?: boolean) => { - handleNavigation('right', alt); - }, - [handleNavigation] - ); - - const handleUpImage = useCallback( - (alt?: boolean) => { - handleNavigation('up', alt); - }, - [handleNavigation] - ); - - const handleDownImage = useCallback( - (alt?: boolean) => { - handleNavigation('down', alt); - }, - [handleNavigation] - ); - - const nextImage = useCallback(() => { - handleRightImage(); - }, [handleRightImage]); - - const prevImage = useCallback(() => { - handleLeftImage(); - }, [handleLeftImage]); - - return { - handleLeftImage, - handleRightImage, - handleUpImage, - handleDownImage, - isOnFirstImage, - isOnLastImage, - isOnFirstRow, - isOnLastRow, - nextImage, - prevImage, - isOnFirstImageOfView, - isOnLastImageOfView, - }; -}; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts deleted file mode 100644 index 07cba0d225..0000000000 --- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryPagination.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { offsetChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import { throttle } from 'lodash-es'; -import { useCallback, useEffect, useMemo } from 'react'; -import { useListImagesQuery } from 'services/api/endpoints/images'; - -// Some logic copied from https://github.com/chakra-ui/zag/blob/1925b7342dc76fb06a7ec59a5a4c0063a4620422/packages/machines/pagination/src/pagination.utils.ts - -const range = (start: number, end: number) => { - const length = end - start + 1; - return Array.from({ length }, (_, idx) => idx + start); -}; - -export const ELLIPSIS = 'ellipsis' as const; - -const getRange = (currentPage: number, totalPages: number, siblingCount: number) => { - /** - * `2 * ctx.siblingCount + 5` explanation: - * 2 * ctx.siblingCount for left/right siblings - * 5 for 2x left/right ellipsis, 2x first/last page + 1x current page - * - * For some page counts (e.g. totalPages: 8, siblingCount: 2), - * calculated max page is higher than total pages, - * so we need to take the minimum of both. - */ - const totalPageNumbers = Math.min(2 * siblingCount + 5, totalPages); - - const firstPageIndex = 1; - const lastPageIndex = totalPages; - - const leftSiblingIndex = Math.max(currentPage - siblingCount, firstPageIndex); - const rightSiblingIndex = Math.min(currentPage + siblingCount, lastPageIndex); - - const showLeftEllipsis = leftSiblingIndex > firstPageIndex + 1; - const showRightEllipsis = rightSiblingIndex < lastPageIndex - 1; - - const itemCount = totalPageNumbers - 2; // 2 stands for one ellipsis and either first or last page - - if (!showLeftEllipsis && showRightEllipsis) { - const leftRange = range(1, itemCount); - return [...leftRange, ELLIPSIS, lastPageIndex]; - } - - if (showLeftEllipsis && !showRightEllipsis) { - const rightRange = range(lastPageIndex - itemCount + 1, lastPageIndex); - return [firstPageIndex, ELLIPSIS, ...rightRange]; - } - - if (showLeftEllipsis && showRightEllipsis) { - const middleRange = range(leftSiblingIndex, rightSiblingIndex); - return [firstPageIndex, ELLIPSIS, ...middleRange, ELLIPSIS, lastPageIndex]; - } - - const fullRange = range(firstPageIndex, lastPageIndex); - return fullRange as (number | 'ellipsis')[]; -}; - -const selectOffset = createSelector(selectGallerySlice, (gallery) => gallery.offset); -const selectLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit); - -export const useGalleryPagination = () => { - const dispatch = useAppDispatch(); - const offset = useAppSelector(selectOffset); - const limit = useAppSelector(selectLimit); - const queryArgs = useAppSelector(selectListImagesQueryArgs); - - const { count, total } = useListImagesQuery(queryArgs, { - selectFromResult: ({ data }) => ({ count: data?.items.length ?? 0, total: data?.total ?? 0 }), - }); - - const currentPage = useMemo(() => Math.ceil(offset / (limit || 0)), [offset, limit]); - const pages = useMemo(() => Math.ceil(total / (limit || 0)), [total, limit]); - - const isNextEnabled = useMemo(() => { - if (!count) { - return false; - } - return currentPage + 1 < pages; - }, [count, currentPage, pages]); - const isPrevEnabled = useMemo(() => { - if (!count) { - return false; - } - return offset > 0; - }, [count, offset]); - - const onOffsetChanged = useCallback( - (arg: Parameters[0]) => { - dispatch(offsetChanged(arg)); - }, - [dispatch] - ); - - const throttledOnOffsetChanged = useMemo(() => throttle(onOffsetChanged, 500), [onOffsetChanged]); - - const goNext = useCallback( - (withHotkey?: 'arrow' | 'alt+arrow') => { - throttledOnOffsetChanged({ offset: offset + (limit || 0), withHotkey }); - }, - [throttledOnOffsetChanged, offset, limit] - ); - - const goPrev = useCallback( - (withHotkey?: 'arrow' | 'alt+arrow') => { - throttledOnOffsetChanged({ offset: Math.max(offset - (limit || 0), 0), withHotkey }); - }, - [throttledOnOffsetChanged, offset, limit] - ); - - const goToPage = useCallback( - (page: number) => { - throttledOnOffsetChanged({ offset: page * (limit || 0) }); - }, - [throttledOnOffsetChanged, limit] - ); - - // handle when total/pages decrease and user is on high page number (ie bulk removing or deleting) - useEffect(() => { - if (pages && currentPage + 1 > pages) { - throttledOnOffsetChanged({ offset: (pages - 1) * (limit || 0) }); - } - }, [currentPage, pages, throttledOnOffsetChanged, limit]); - - const pageButtons = useMemo(() => { - if (pages > 7) { - return getRange(currentPage + 1, pages, 1); - } - return range(1, pages); - }, [currentPage, pages]); - - return { - goPrev, - goNext, - isPrevEnabled, - isNextEnabled, - pageButtons, - goToPage, - currentPage, - total, - pages, - }; -}; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx index 4f228ac240..1a1001ab9f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx @@ -4,6 +4,7 @@ import type { NodeProps } from '@xyflow/react'; import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { DndImage } from 'features/dnd/DndImage'; +import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper'; import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; @@ -74,7 +75,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => { {props.children} {isHovering && ( - {/* */} + )} From b2b42be51cafddf8f2c8b6a6fc8cf3a7ec47b9c6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:44:57 +1000 Subject: [PATCH 168/210] refactor: remove unused methods/routes, fix some gallery invalidation issues --- invokeai/app/api/routers/images.py | 66 +----- invokeai/app/invocations/baseinvocation.py | 12 +- invokeai/app/invocations/segment_anything.py | 6 +- .../app/services/config/config_default.py | 6 +- .../image_records/image_records_base.py | 32 +-- .../image_records/image_records_sqlite.py | 199 ++---------------- invokeai/app/services/images/images_base.py | 34 +-- .../app/services/images/images_default.py | 70 +----- .../workflow_records_sqlite.py | 12 +- invokeai/backend/model_manager/config.py | 2 +- invokeai/backend/model_manager/merge.py | 18 +- .../listeners/boardIdSelected.ts | 4 +- .../listeners/galleryImageClicked.ts | 28 +-- .../features/deleteImageModal/store/state.ts | 11 +- .../ImageViewer/CurrentImagePreview.tsx | 25 +++ .../gallery/components/NewGallery.tsx | 23 +- .../gallery/store/gallerySelectors.ts | 28 +-- .../web/src/services/api/endpoints/images.ts | 94 +-------- .../frontend/web/src/services/api/schema.ts | 102 ++------- .../services/events/onInvocationComplete.tsx | 6 +- .../services/download/test_download_queue.py | 12 +- ...st_flux_aitoolkit_lora_conversion_utils.py | 6 +- 22 files changed, 139 insertions(+), 657 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index d183c614c4..a9bcc9f768 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -1,7 +1,7 @@ import io import json import traceback -from typing import ClassVar, Literal, Optional +from typing import ClassVar, Optional from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile from fastapi.responses import FileResponse @@ -14,7 +14,6 @@ from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_i from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, - ImageCollectionCounts, ImageRecordChanges, ResourceOrigin, ) @@ -565,67 +564,6 @@ async def get_bulk_download_item( raise HTTPException(status_code=404) -@images_router.get( - "/collections/counts", operation_id="get_image_collection_counts", response_model=ImageCollectionCounts -) -async def get_image_collection_counts( - image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to count."), - categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), - is_intermediate: Optional[bool] = Query(default=None, description="Whether to include intermediate images."), - board_id: Optional[str] = Query( - default=None, - description="The board id to filter by. Use 'none' to find images without a board.", - ), - search_term: Optional[str] = Query(default=None, description="The term to search for"), -) -> ImageCollectionCounts: - """Gets counts for starred and unstarred image collections""" - - try: - return ApiDependencies.invoker.services.images.get_collection_counts( - image_origin=image_origin, - categories=categories, - is_intermediate=is_intermediate, - board_id=board_id, - search_term=search_term, - ) - except Exception: - raise HTTPException(status_code=500, detail="Failed to get collection counts") - - -@images_router.get("/collections/{collection}", operation_id="get_image_collection") -async def get_image_collection( - collection: Literal["starred", "unstarred"] = Path(..., description="The collection to retrieve from"), - image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), - categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), - is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."), - board_id: Optional[str] = Query( - default=None, - description="The board id to filter by. Use 'none' to find images without a board.", - ), - offset: int = Query(default=0, description="The offset within the collection"), - limit: int = Query(default=50, description="The number of images to return"), - order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), - search_term: Optional[str] = Query(default=None, description="The term to search for"), -) -> OffsetPaginatedResults[ImageDTO]: - """Gets images from a specific collection (starred or unstarred)""" - - try: - image_dtos = ApiDependencies.invoker.services.images.get_collection_images( - collection=collection, - offset=offset, - limit=limit, - order_dir=order_dir, - image_origin=image_origin, - categories=categories, - is_intermediate=is_intermediate, - board_id=board_id, - search_term=search_term, - ) - return image_dtos - except Exception: - raise HTTPException(status_code=500, detail="Failed to get collection images") - - @images_router.get("/names", operation_id="get_image_names") async def get_image_names( image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), @@ -636,12 +574,14 @@ async def get_image_names( description="The board id to filter by. Use 'none' to find images without a board.", ), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> list[str]: """Gets ordered list of all image names (starred first, then unstarred)""" try: image_names = ApiDependencies.invoker.services.images.get_image_names( + starred_first=starred_first, order_dir=order_dir, image_origin=image_origin, categories=categories, diff --git a/invokeai/app/invocations/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py index 622d8ea60f..4f2341fbcd 100644 --- a/invokeai/app/invocations/baseinvocation.py +++ b/invokeai/app/invocations/baseinvocation.py @@ -587,9 +587,9 @@ def invocation( for field_name, field_info in cls.model_fields.items(): annotation = field_info.annotation assert annotation is not None, f"{field_name} on invocation {invocation_type} has no type annotation." - assert isinstance(field_info.json_schema_extra, dict), ( - f"{field_name} on invocation {invocation_type} has a non-dict json_schema_extra, did you forget to use InputField?" - ) + assert isinstance( + field_info.json_schema_extra, dict + ), f"{field_name} on invocation {invocation_type} has a non-dict json_schema_extra, did you forget to use InputField?" original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info) @@ -712,9 +712,9 @@ def invocation_output( for field_name, field_info in cls.model_fields.items(): annotation = field_info.annotation assert annotation is not None, f"{field_name} on invocation output {output_type} has no type annotation." - assert isinstance(field_info.json_schema_extra, dict), ( - f"{field_name} on invocation output {output_type} has a non-dict json_schema_extra, did you forget to use InputField?" - ) + assert isinstance( + field_info.json_schema_extra, dict + ), f"{field_name} on invocation output {output_type} has a non-dict json_schema_extra, did you forget to use InputField?" cls._original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info) diff --git a/invokeai/app/invocations/segment_anything.py b/invokeai/app/invocations/segment_anything.py index 4d82624edf..6b2decff18 100644 --- a/invokeai/app/invocations/segment_anything.py +++ b/invokeai/app/invocations/segment_anything.py @@ -184,9 +184,9 @@ class SegmentAnythingInvocation(BaseInvocation): # Find the largest mask. return [max(masks, key=lambda x: float(x.sum()))] elif self.mask_filter == "highest_box_score": - assert bounding_boxes is not None, ( - "Bounding boxes must be provided to use the 'highest_box_score' mask filter." - ) + assert ( + bounding_boxes is not None + ), "Bounding boxes must be provided to use the 'highest_box_score' mask filter." assert len(masks) == len(bounding_boxes) # Find the index of the bounding box with the highest score. # Note that we fallback to -1.0 if the score is None. This is mainly to satisfy the type checker. In most diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 4dabac964b..18fd5c70db 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -482,9 +482,9 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig: try: # Meta is not included in the model fields, so we need to validate it separately config = InvokeAIAppConfig.model_validate(loaded_config_dict) - assert config.schema_version == CONFIG_SCHEMA_VERSION, ( - f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" - ) + assert ( + config.schema_version == CONFIG_SCHEMA_VERSION + ), f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" return config except Exception as e: raise RuntimeError(f"Failed to load config file {config_path}: {e}") from e diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index e640e3facb..128ced7b09 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -1,11 +1,10 @@ from abc import ABC, abstractmethod from datetime import datetime -from typing import Literal, Optional +from typing import Optional from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, - ImageCollectionCounts, ImageRecord, ImageRecordChanges, ResourceOrigin, @@ -99,37 +98,10 @@ class ImageRecordStorageBase(ABC): """Gets the most recent image for a board.""" pass - @abstractmethod - def get_collection_counts( - self, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> ImageCollectionCounts: - """Gets counts for starred and unstarred image collections.""" - pass - - @abstractmethod - def get_collection_images( - self, - collection: Literal["starred", "unstarred"], - offset: int = 0, - limit: int = 10, - order_dir: SQLiteDirection = SQLiteDirection.Descending, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> OffsetPaginatedResults[ImageRecord]: - """Gets images from a specific collection (starred or unstarred).""" - pass - @abstractmethod def get_image_names( self, + starred_first: bool = True, order_dir: SQLiteDirection = SQLiteDirection.Descending, image_origin: Optional[ResourceOrigin] = None, categories: Optional[list[ImageCategory]] = None, diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index 704b99bd77..3086880560 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -1,13 +1,12 @@ import sqlite3 from datetime import datetime -from typing import Literal, Optional, Union, cast +from typing import Optional, Union, cast from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase from invokeai.app.services.image_records.image_records_common import ( IMAGE_DTO_COLS, ImageCategory, - ImageCollectionCounts, ImageRecord, ImageRecordChanges, ImageRecordDeleteException, @@ -388,182 +387,9 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): return deserialize_image_record(dict(result)) - def get_collection_counts( - self, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> ImageCollectionCounts: - cursor = self._conn.cursor() - - # Build the base query conditions (same as get_many) - base_query = """--sql - FROM images - LEFT JOIN board_images ON board_images.image_name = images.image_name - WHERE 1=1 - """ - - query_conditions = "" - query_params: list[Union[int, str, bool]] = [] - - if image_origin is not None: - query_conditions += """--sql - AND images.image_origin = ? - """ - query_params.append(image_origin.value) - - if categories is not None: - category_strings = [c.value for c in set(categories)] - placeholders = ",".join("?" * len(category_strings)) - query_conditions += f"""--sql - AND images.image_category IN ( {placeholders} ) - """ - for c in category_strings: - query_params.append(c) - - if is_intermediate is not None: - query_conditions += """--sql - AND images.is_intermediate = ? - """ - query_params.append(is_intermediate) - - if board_id == "none": - query_conditions += """--sql - AND board_images.board_id IS NULL - """ - elif board_id is not None: - query_conditions += """--sql - AND board_images.board_id = ? - """ - query_params.append(board_id) - - if search_term: - query_conditions += """--sql - AND ( - images.metadata LIKE ? - OR images.created_at LIKE ? - ) - """ - query_params.append(f"%{search_term.lower()}%") - query_params.append(f"%{search_term.lower()}%") - - # Get starred count - starred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = TRUE;" - cursor.execute(starred_query, query_params) - starred_count = cast(int, cursor.fetchone()[0]) - - # Get unstarred count - unstarred_query = f"SELECT COUNT(*) {base_query} {query_conditions} AND images.starred = FALSE;" - cursor.execute(unstarred_query, query_params) - unstarred_count = cast(int, cursor.fetchone()[0]) - - return ImageCollectionCounts(starred_count=starred_count, unstarred_count=unstarred_count) - - def get_collection_images( - self, - collection: Literal["starred", "unstarred"], - offset: int = 0, - limit: int = 10, - order_dir: SQLiteDirection = SQLiteDirection.Descending, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> OffsetPaginatedResults[ImageRecord]: - cursor = self._conn.cursor() - - # Base queries - count_query = """--sql - SELECT COUNT(*) - FROM images - LEFT JOIN board_images ON board_images.image_name = images.image_name - WHERE 1=1 - """ - - images_query = f"""--sql - SELECT {IMAGE_DTO_COLS} - FROM images - LEFT JOIN board_images ON board_images.image_name = images.image_name - WHERE 1=1 - """ - - query_conditions = "" - query_params: list[Union[int, str, bool]] = [] - - # Add starred/unstarred filter - is_starred = collection == "starred" - query_conditions += """--sql - AND images.starred = ? - """ - query_params.append(is_starred) - - if image_origin is not None: - query_conditions += """--sql - AND images.image_origin = ? - """ - query_params.append(image_origin.value) - - if categories is not None: - category_strings = [c.value for c in set(categories)] - placeholders = ",".join("?" * len(category_strings)) - query_conditions += f"""--sql - AND images.image_category IN ( {placeholders} ) - """ - for c in category_strings: - query_params.append(c) - - if is_intermediate is not None: - query_conditions += """--sql - AND images.is_intermediate = ? - """ - query_params.append(is_intermediate) - - if board_id == "none": - query_conditions += """--sql - AND board_images.board_id IS NULL - """ - elif board_id is not None: - query_conditions += """--sql - AND board_images.board_id = ? - """ - query_params.append(board_id) - - if search_term: - query_conditions += """--sql - AND ( - images.metadata LIKE ? - OR images.created_at LIKE ? - ) - """ - query_params.append(f"%{search_term.lower()}%") - query_params.append(f"%{search_term.lower()}%") - - # Add ordering and pagination - query_pagination = f"""--sql - ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ? - """ - - # Execute images query - images_query += query_conditions + query_pagination + ";" - images_params = query_params.copy() - images_params.extend([limit, offset]) - - cursor.execute(images_query, images_params) - result = cast(list[sqlite3.Row], cursor.fetchall()) - images = [deserialize_image_record(dict(r)) for r in result] - - # Execute count query - count_query += query_conditions + ";" - cursor.execute(count_query, query_params) - count = cast(int, cursor.fetchone()[0]) - - return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count) - def get_image_names( self, + starred_first: bool = True, order_dir: SQLiteDirection = SQLiteDirection.Descending, image_origin: Optional[ResourceOrigin] = None, categories: Optional[list[ImageCategory]] = None, @@ -625,13 +451,20 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): query_params.append(f"%{search_term.lower()}%") query_params.append(f"%{search_term.lower()}%") - # Order by starred first, then by created_at - query += ( - query_conditions - + f"""--sql - ORDER BY images.starred DESC, images.created_at {order_dir.value} - """ - ) + if starred_first: + query += ( + query_conditions + + f"""--sql + ORDER BY images.starred DESC, images.created_at {order_dir.value} + """ + ) + else: + query += ( + query_conditions + + f"""--sql + ORDER BY images.created_at {order_dir.value} + """ + ) cursor.execute(query, query_params) result = cast(list[sqlite3.Row], cursor.fetchall()) diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index 037d463e33..3bf832cc71 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -1,12 +1,11 @@ from abc import ABC, abstractmethod -from typing import Callable, Literal, Optional +from typing import Callable, Optional from PIL.Image import Image as PILImageType from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, - ImageCollectionCounts, ImageRecord, ImageRecordChanges, ResourceOrigin, @@ -149,37 +148,10 @@ class ImageServiceABC(ABC): """Deletes all images on a board.""" pass - @abstractmethod - def get_collection_counts( - self, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> ImageCollectionCounts: - """Gets counts for starred and unstarred image collections.""" - pass - - @abstractmethod - def get_collection_images( - self, - collection: Literal["starred", "unstarred"], - offset: int = 0, - limit: int = 10, - order_dir: SQLiteDirection = SQLiteDirection.Descending, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> OffsetPaginatedResults[ImageDTO]: - """Gets images from a specific collection (starred or unstarred).""" - pass - @abstractmethod def get_image_names( self, + starred_first: bool = True, order_dir: SQLiteDirection = SQLiteDirection.Descending, image_origin: Optional[ResourceOrigin] = None, categories: Optional[list[ImageCategory]] = None, @@ -187,5 +159,5 @@ class ImageServiceABC(ABC): board_id: Optional[str] = None, search_term: Optional[str] = None, ) -> list[str]: - """Gets ordered list of all image names (starred first, then unstarred).""" + """Gets ordered list of all image names.""" pass diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 6585e7ca05..4547d46c04 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional +from typing import Optional from PIL.Image import Image as PILImageType @@ -10,7 +10,6 @@ from invokeai.app.services.image_files.image_files_common import ( ) from invokeai.app.services.image_records.image_records_common import ( ImageCategory, - ImageCollectionCounts, ImageRecord, ImageRecordChanges, ImageRecordDeleteException, @@ -311,73 +310,9 @@ class ImageService(ImageServiceABC): self.__invoker.services.logger.error("Problem getting intermediates count") raise e - def get_collection_counts( - self, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> ImageCollectionCounts: - try: - return self.__invoker.services.image_records.get_collection_counts( - image_origin=image_origin, - categories=categories, - is_intermediate=is_intermediate, - board_id=board_id, - search_term=search_term, - ) - except Exception as e: - self.__invoker.services.logger.error("Problem getting collection counts") - raise e - - def get_collection_images( - self, - collection: Literal["starred", "unstarred"], - offset: int = 0, - limit: int = 10, - order_dir: SQLiteDirection = SQLiteDirection.Descending, - image_origin: Optional[ResourceOrigin] = None, - categories: Optional[list[ImageCategory]] = None, - is_intermediate: Optional[bool] = None, - board_id: Optional[str] = None, - search_term: Optional[str] = None, - ) -> OffsetPaginatedResults[ImageDTO]: - try: - results = self.__invoker.services.image_records.get_collection_images( - collection=collection, - offset=offset, - limit=limit, - order_dir=order_dir, - image_origin=image_origin, - categories=categories, - is_intermediate=is_intermediate, - board_id=board_id, - search_term=search_term, - ) - - image_dtos = [ - image_record_to_dto( - image_record=r, - image_url=self.__invoker.services.urls.get_image_url(r.image_name), - thumbnail_url=self.__invoker.services.urls.get_image_url(r.image_name, True), - board_id=self.__invoker.services.board_image_records.get_board_for_image(r.image_name), - ) - for r in results.items - ] - - return OffsetPaginatedResults[ImageDTO]( - items=image_dtos, - offset=results.offset, - limit=results.limit, - total=results.total, - ) - except Exception as e: - self.__invoker.services.logger.error("Problem getting collection images") - raise e - def get_image_names( self, + starred_first: bool = True, order_dir: SQLiteDirection = SQLiteDirection.Descending, image_origin: Optional[ResourceOrigin] = None, categories: Optional[list[ImageCategory]] = None, @@ -387,6 +322,7 @@ class ImageService(ImageServiceABC): ) -> list[str]: try: return self.__invoker.services.image_records.get_image_names( + starred_first=starred_first, order_dir=order_dir, image_origin=image_origin, categories=categories, diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index b84b226d9f..367c00b503 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -379,13 +379,13 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase): bytes_ = path.read_bytes() workflow_from_file = WorkflowValidator.validate_json(bytes_) - assert workflow_from_file.id.startswith("default_"), ( - f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' - ) + assert workflow_from_file.id.startswith( + "default_" + ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' - assert workflow_from_file.meta.category is WorkflowCategory.Default, ( - f"Invalid default workflow category: {workflow_from_file.meta.category}" - ) + assert ( + workflow_from_file.meta.category is WorkflowCategory.Default + ), f"Invalid default workflow category: {workflow_from_file.meta.category}" workflows_from_file.append(workflow_from_file) diff --git a/invokeai/backend/model_manager/config.py b/invokeai/backend/model_manager/config.py index 0c07e6c53e..7521f2c512 100644 --- a/invokeai/backend/model_manager/config.py +++ b/invokeai/backend/model_manager/config.py @@ -381,7 +381,7 @@ class LoRALyCORISConfig(LoRAConfigBase, ModelConfigBase): state_dict = mod.load_state_dict() for key in state_dict.keys(): - if type(key) is int: + if isinstance(key, int): continue if key.startswith(("lora_te_", "lora_unet_", "lora_te1_", "lora_te2_", "lora_transformer_")): diff --git a/invokeai/backend/model_manager/merge.py b/invokeai/backend/model_manager/merge.py index 03056b10f5..b00bc99f3e 100644 --- a/invokeai/backend/model_manager/merge.py +++ b/invokeai/backend/model_manager/merge.py @@ -115,19 +115,19 @@ class ModelMerger(object): base_models: Set[BaseModelType] = set() variant = None if self._installer.app_config.precision == "float32" else "fp16" - assert len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference, ( - "When merging three models, only the 'add_difference' merge method is supported" - ) + assert ( + len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference + ), "When merging three models, only the 'add_difference' merge method is supported" for key in model_keys: info = store.get_model(key) model_names.append(info.name) - assert isinstance(info, MainDiffusersConfig), ( - f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" - ) - assert info.variant == ModelVariantType("normal"), ( - f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" - ) + assert isinstance( + info, MainDiffusersConfig + ), f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" + assert info.variant == ModelVariantType( + "normal" + ), f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" # tally base models used base_models.add(info.base) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 899e88a85c..52f9e5cdeb 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,6 +1,6 @@ import { isAnyOf } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images'; @@ -13,7 +13,7 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) const state = getState(); - const queryArgs = selectListImagesQueryArgs(state); + const queryArgs = { ...selectListImagesBaseQueryArgs(state), offset: 0 }; // wait until the board has some images - maybe it already has some from a previous fetch // must use getState() to ensure we do not have stale state diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts index f95b7502f0..efa927ce64 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -1,29 +1,9 @@ import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import type { RootState } from 'app/store/store'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectListImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { uniq } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; -import type { ImageCategory, SQLiteDirection } from 'services/api/types'; - -// Type for image collection query arguments -type ImageCollectionQueryArgs = { - board_id?: string; - categories?: ImageCategory[]; - search_term?: string; - order_dir?: SQLiteDirection; - is_intermediate: boolean; -}; - -/** - * Helper function to get cached image names list for selection operations - * Returns an ordered array of image names (starred first, then unstarred) - */ -const getCachedImageNames = (state: RootState, queryArgs: ImageCollectionQueryArgs): string[] => { - const queryResult = imagesApi.endpoints.getImageNames.select(queryArgs)(state); - return queryResult.data || []; -}; export const galleryImageClicked = createAction<{ imageName: string; @@ -50,10 +30,8 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen effect: (action, { dispatch, getState }) => { const { imageName, shiftKey, ctrlKey, metaKey, altKey } = action.payload; const state = getState(); - const queryArgs = selectListImagesQueryArgs(state); - - // Get cached image names for selection operations - const imageNames = getCachedImageNames(state, queryArgs); + const queryArgs = selectListImageNamesQueryArgs(state); + const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(state).data ?? []; // If we don't have the image names cached, we can't perform selection operations // This can happen if the user clicks on an image before the names are loaded diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts index c04f7dd294..a380ec02d6 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts @@ -10,7 +10,6 @@ import { import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import type { CanvasState, RefImagesState } from 'features/controlLayers/store/types'; import type { ImageUsage } from 'features/deleteImageModal/store/types'; -import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { imageSelected } from 'features/gallery/store/gallerySlice'; import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; @@ -81,14 +80,8 @@ const handleDeletions = async (image_names: string[], dispatch: AppDispatch, get await dispatch(imagesApi.endpoints.deleteImages.initiate({ image_names }, { track: false })).unwrap(); if (intersection(state.gallery.selection, image_names).length > 0) { - // Some selected images were deleted, need to select the next image - const queryArgs = selectListImagesQueryArgs(state); - const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state); - if (data) { - // When we delete multiple images, we clear the selection. Then, the the next time we load images, we will - // select the first one. This is handled below in the listener for `imagesApi.endpoints.listImages.matchFulfilled`. - dispatch(imageSelected(null)); - } + // Some selected images were deleted, clear selection + dispatch(imageSelected(null)); } // We need to reset the features where the image is in use - none of these work if their image(s) don't exist diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index faa0bd91ad..1b4ce009f2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -5,6 +5,7 @@ import { CanvasAlertsInvocationProgress } from 'features/controlLayers/component import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; +import { selectAutoSwitch } from 'features/gallery/store/gallerySelectors'; import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; @@ -21,6 +22,7 @@ import { ProgressIndicator } from './ProgressIndicator2'; export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => { const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + const autoSwitch = useAppSelector(selectAutoSwitch); const socket = useStore($socket); const [progressEvent, setProgressEvent] = useState(null); @@ -58,6 +60,29 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu }; }, [socket]); + useEffect(() => { + if (!socket) { + return; + } + + if (autoSwitch) { + return; + } + // When auto-switch is enabled, we will get a load event as we switch to the new image. This in turn clears the progress image, + // creating the illusion of the progress image turning into the new image. + // But when auto-switch is disabled, we won't get that load event, so we need to clear the progress image manually. + const onQueueItemStatusChanged = () => { + setProgressEvent(null); + setProgressImage(null); + }; + + socket.on('queue_item_status_changed', onQueueItemStatusChanged); + + return () => { + socket.off('queue_item_status_changed', onQueueItemStatusChanged); + }; + }, [autoSwitch, socket]); + const onLoadImage = useCallback(() => { if (!progressEvent || !imageDTO) { return; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index eea3395dd8..2eafcefe33 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -4,10 +4,11 @@ import { logger } from 'app/logging/logger'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { + LIMIT, selectGalleryImageMinimumWidth, selectImageToCompare, selectLastSelectedImage, - selectListImagesQueryArgs, + selectListImageNamesQueryArgs, } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; @@ -37,31 +38,26 @@ const SCROLL_SEEK_VELOCITY_THRESHOLD = 4096; const DEBOUNCE_DELAY = 500; const SPINNER_OPACITY = 0.3; -type ListImagesQueryArgs = ReturnType; +type ListImageNamesQueryArgs = ReturnType; type GridContext = { - queryArgs: ListImagesQueryArgs; + queryArgs: ListImageNamesQueryArgs; imageNames: string[]; }; -export const useDebouncedListImagesQueryArgs = () => { - const _galleryQueryArgs = useAppSelector(selectListImagesQueryArgs); - const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY); - return queryArgs; -}; - // Hook to get an image DTO from cache or trigger loading const useImageDTOFromListQuery = ( index: number, imageName: string, - queryArgs: ListImagesQueryArgs + queryArgs: ListImageNamesQueryArgs ): ImageDTO | null => { const { arg, options } = useMemo(() => { - const pageOffset = Math.floor(index / queryArgs.limit) * queryArgs.limit; + const pageOffset = Math.floor(index / LIMIT) * LIMIT; return { arg: { ...queryArgs, offset: pageOffset, + limit: LIMIT, } satisfies Parameters[0], options: { selectFromResult: ({ data }) => { @@ -82,7 +78,7 @@ const useImageDTOFromListQuery = ( // Individual image component that gets its data from RTK Query cache const ImageAtPosition = memo( - ({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImagesQueryArgs }) => { + ({ index, queryArgs, imageName }: { index: number; imageName: string; queryArgs: ListImageNamesQueryArgs }) => { const imageDTO = useImageDTOFromListQuery(index, imageName, queryArgs); if (!imageDTO) { @@ -408,7 +404,8 @@ const getImageNamesQueryOptions = { } satisfies Parameters[1]; export const useGalleryImageNames = () => { - const queryArgs = useDebouncedListImagesQueryArgs(); + const _queryArgs = useAppSelector(selectListImageNamesQueryArgs); + const [queryArgs] = useDebounce(_queryArgs, DEBOUNCE_DELAY); const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); return { imageNames, isLoading, isFetching, queryArgs }; }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index f5355a7ba0..e2062d23db 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -2,8 +2,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types'; -import type { SetNonNullable } from 'type-fest'; +import type { ListBoardsArgs } from 'services/api/types'; export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); @@ -28,7 +27,7 @@ export const selectGallerySearchTerm = createSelector(selectGallerySlice, (galle export const selectGalleryOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.orderDir); export const selectGalleryStarredFirst = createSelector(selectGallerySlice, (gallery) => gallery.starredFirst); -export const selectListImagesQueryArgs = createMemoizedSelector( +export const selectListImageNamesQueryArgs = createMemoizedSelector( [ selectSelectedBoardId, selectGalleryQueryCategories, @@ -36,17 +35,20 @@ export const selectListImagesQueryArgs = createMemoizedSelector( selectGalleryOrderDir, selectGalleryStarredFirst, ], - (board_id, categories, search_term, order_dir, starred_first) => - ({ - board_id, - categories, - search_term, - order_dir, - starred_first, - is_intermediate: false, // We don't show intermediate images in the gallery - limit: 100, // Page size is _always_ 100 - }) satisfies SetNonNullable + (board_id, categories, search_term, order_dir, starred_first) => ({ + board_id, + categories, + search_term, + order_dir, + starred_first, + is_intermediate: false, + }) ); +export const LIMIT = 100; +export const selectListImagesBaseQueryArgs = createMemoizedSelector(selectListImageNamesQueryArgs, (baseQueryArgs) => ({ + ...baseQueryArgs, + limit: LIMIT, +})); export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, (gallery) => gallery.autoAssignBoardOnClick diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index cd7303e069..2d3184627e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -427,61 +427,12 @@ export const imagesApi = api.injectEndpoints({ }, }), }), - /** - * Get counts for starred and unstarred image collections - */ - getImageCollectionCounts: build.query< - paths['/api/v1/images/collections/counts']['get']['responses']['200']['content']['application/json'], - paths['/api/v1/images/collections/counts']['get']['parameters']['query'] - >({ - query: (queryArgs) => ({ - url: buildImagesUrl('collections/counts', queryArgs), - method: 'GET', - }), - providesTags: ['ImageCollectionCounts', 'FetchOnReconnect'], - }), - /** - * Get images from a specific collection (starred or unstarred) - */ - getImageCollection: build.query< - paths['/api/v1/images/collections/{collection}']['get']['responses']['200']['content']['application/json'], - paths['/api/v1/images/collections/{collection}']['get']['parameters']['path'] & - paths['/api/v1/images/collections/{collection}']['get']['parameters']['query'] - >({ - query: ({ collection, ...queryArgs }) => ({ - url: buildImagesUrl(`collections/${collection}`, queryArgs), - method: 'GET', - }), - providesTags: (result, error, { collection, board_id, categories }) => { - const cacheKey = `${collection}-${board_id || 'all'}-${categories?.join(',') || 'all'}`; - return [ - { type: 'ImageCollection', id: collection }, - { type: 'ImageCollection', id: cacheKey }, - 'FetchOnReconnect', - ]; - }, - async onQueryStarted(_, { dispatch, queryFulfilled }) { - // Populate the getImageDTO cache with these images, similar to listImages - const res = await queryFulfilled; - const imageDTOs = res.data.items; - const updates: Param0 = []; - for (const imageDTO of imageDTOs) { - updates.push({ - endpointName: 'getImageDTO', - arg: imageDTO.image_name, - value: imageDTO, - }); - } - dispatch(imagesApi.util.upsertQueryEntries(updates)); - }, - }), /** * Get ordered list of image names for selection operations */ getImageNames: build.query< string[], { - image_origin?: 'internal' | 'external' | null; categories?: ImageCategory[] | null; is_intermediate?: boolean | null; board_id?: string | null; @@ -493,46 +444,11 @@ export const imagesApi = api.injectEndpoints({ url: buildImagesUrl('names', queryArgs), method: 'GET', }), - providesTags: ['ImageNameList', 'FetchOnReconnect'], - }), - /** - * Get paginated images with starred first (unified list) - */ - getUnifiedImageList: build.query< - ListImagesResponse, - { - offset?: number; - limit?: number; - image_origin?: 'internal' | 'external' | null; - categories?: ImageCategory[] | null; - is_intermediate?: boolean | null; - board_id?: string | null; - search_term?: string | null; - order_dir?: SQLiteDirection; - } - >({ - query: (queryArgs) => ({ - url: getListImagesUrl({ ...queryArgs, starred_first: true }), - method: 'GET', - }), - providesTags: (result, error, { board_id, categories }) => [ - { type: 'ImageList', id: getListImagesUrl({ board_id, categories }) }, + providesTags: (result, error, queryArgs) => [ + 'ImageNameList', 'FetchOnReconnect', + { type: 'ImageNameList', id: stableHash(queryArgs) }, ], - async onQueryStarted(_, { dispatch, queryFulfilled }) { - // Populate the getImageDTO cache with these images - const res = await queryFulfilled; - const imageDTOs = res.data.items; - const updates: Param0 = []; - for (const imageDTO of imageDTOs) { - updates.push({ - endpointName: 'getImageDTO', - arg: imageDTO.image_name, - value: imageDTO, - }); - } - dispatch(imagesApi.util.upsertQueryEntries(updates)); - }, }), }), }); @@ -555,11 +471,7 @@ export const { useStarImagesMutation, useUnstarImagesMutation, useBulkDownloadImagesMutation, - useGetImageCollectionCountsQuery, - useGetImageCollectionQuery, - useLazyGetImageCollectionQuery, useGetImageNamesQuery, - useGetUnifiedImageListQuery, } = imagesApi; /** diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 0c01dacfe0..ae843df433 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -752,7 +752,7 @@ export type paths = { patch?: never; trace?: never; }; - "/api/v1/images/collections/counts": { + "/api/v1/images/names": { parameters: { query?: never; header?: never; @@ -760,30 +760,10 @@ export type paths = { cookie?: never; }; /** - * Get Image Collection Counts - * @description Gets counts for starred and unstarred image collections + * Get Image Names + * @description Gets ordered list of all image names (starred first, then unstarred) */ - get: operations["get_image_collection_counts"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/images/collections/{collection}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Image Collection - * @description Gets images from a specific collection (starred or unstarred) - */ - get: operations["get_image_collection"]; + get: operations["get_image_names"]; put?: never; post?: never; delete?: never; @@ -9844,19 +9824,6 @@ export type components = { */ type: "img_channel_offset"; }; - /** ImageCollectionCounts */ - ImageCollectionCounts: { - /** - * Starred Count - * @description The number of starred images in the collection. - */ - starred_count: number; - /** - * Unstarred Count - * @description The number of unstarred images in the collection. - */ - unstarred_count: number; - }; /** * Image Collection Primitive * @description A collection of image primitive values @@ -23728,17 +23695,21 @@ export interface operations { }; }; }; - get_image_collection_counts: { + get_image_names: { parameters: { query?: { - /** @description The origin of images to count. */ + /** @description The origin of images to list. */ image_origin?: components["schemas"]["ResourceOrigin"] | null; /** @description The categories of image to include. */ categories?: components["schemas"]["ImageCategory"][] | null; - /** @description Whether to include intermediate images. */ + /** @description Whether to list intermediate images. */ is_intermediate?: boolean | null; /** @description The board id to filter by. Use 'none' to find images without a board. */ board_id?: string | null; + /** @description The order of sort */ + order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description Whether to sort by starred images first */ + starred_first?: boolean; /** @description The term to search for */ search_term?: string | null; }; @@ -23754,56 +23725,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ImageCollectionCounts"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_image_collection: { - parameters: { - query?: { - /** @description The origin of images to list. */ - image_origin?: components["schemas"]["ResourceOrigin"] | null; - /** @description The categories of image to include. */ - categories?: components["schemas"]["ImageCategory"][] | null; - /** @description Whether to list intermediate images. */ - is_intermediate?: boolean | null; - /** @description The board id to filter by. Use 'none' to find images without a board. */ - board_id?: string | null; - /** @description The offset within the collection */ - offset?: number; - /** @description The number of images to return */ - limit?: number; - /** @description The order of sort */ - order_dir?: components["schemas"]["SQLiteDirection"]; - /** @description The term to search for */ - search_term?: string | null; - }; - header?: never; - path: { - /** @description The collection to retrieve from */ - collection: "starred" | "unstarred"; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"]; + "application/json": string[]; }; }; /** @description Validation Error */ diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 78322f2408..07ecd537d7 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -5,7 +5,7 @@ import { deepClone } from 'common/util/deepClone'; import { selectAutoSwitch, selectGalleryView, - selectListImagesQueryArgs, + selectListImagesBaseQueryArgs, selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; @@ -44,7 +44,7 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi const boardTotalAdditions: Record = {}; const boardTagIdsToInvalidate: Set = new Set(); const imageListTagIdsToInvalidate: Set = new Set(); - const listImagesArg = selectListImagesQueryArgs(getState()); + const listImagesArg = selectListImagesBaseQueryArgs(getState()); for (const imageDTO of imageDTOs) { if (imageDTO.is_intermediate) { @@ -94,7 +94,7 @@ export const buildOnInvocationComplete = (getState: AppGetState, dispatch: AppDi type: 'ImageList' as const, id: imageListId, })); - dispatch(imagesApi.util.invalidateTags([...boardTags, ...imageListTags])); + dispatch(imagesApi.util.invalidateTags(['ImageNameList', ...boardTags, ...imageListTags])); const autoSwitch = selectAutoSwitch(getState()); diff --git a/tests/app/services/download/test_download_queue.py b/tests/app/services/download/test_download_queue.py index edf3c115ac..8feb49f999 100644 --- a/tests/app/services/download/test_download_queue.py +++ b/tests/app/services/download/test_download_queue.py @@ -211,12 +211,12 @@ def test_multifile_download(tmp_path: Path, mm2_session: Session) -> None: assert job.bytes > 0, "expected download bytes to be positive" assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes" assert job.download_path == tmp_path / "sdxl-turbo" - assert Path(tmp_path, "sdxl-turbo/model_index.json").exists(), ( - f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" - ) - assert Path(tmp_path, "sdxl-turbo/text_encoder/config.json").exists(), ( - f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" - ) + assert Path( + tmp_path, "sdxl-turbo/model_index.json" + ).exists(), f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" + assert Path( + tmp_path, "sdxl-turbo/text_encoder/config.json" + ).exists(), f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED} queue.stop() diff --git a/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py b/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py index ed3e05a9b2..1ad408861e 100644 --- a/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py +++ b/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py @@ -48,9 +48,9 @@ def test_flux_aitoolkit_transformer_state_dict_is_in_invoke_format(): model_keys = set(model.state_dict().keys()) for converted_key_prefix in converted_key_prefixes: - assert any(model_key.startswith(converted_key_prefix) for model_key in model_keys), ( - f"'{converted_key_prefix}' did not match any model keys." - ) + assert any( + model_key.startswith(converted_key_prefix) for model_key in model_keys + ), f"'{converted_key_prefix}' did not match any model keys." def test_lora_model_from_flux_aitoolkit_state_dict(): From f7b249252d5984c1826fb61ce9802c1365a290ef Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:51:29 +1000 Subject: [PATCH 169/210] fix(ui): issues with progress viewer --- .../gallery/components/ImageViewer/CurrentImageButtons.tsx | 1 - .../gallery/components/ImageViewer/CurrentImagePreview.tsx | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 987b6cd911..8e92c37923 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -30,7 +30,6 @@ export const CurrentImageButtons = memo(() => { const ctx = useImageViewerContext(); const hasProgressImage = useStore(ctx.$hasProgressImage); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); - const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer; const imageName = useAppSelector(selectLastSelectedImage); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 1b4ce009f2..c57781b207 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -88,6 +88,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu return; } if (progressEvent.session_id === imageDTO.session_id) { + setProgressEvent(null); setProgressImage(null); } }, [imageDTO, progressEvent]); From 937c03f2ecbca1100273a634c65f63de8b25df96 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:51:58 +1000 Subject: [PATCH 170/210] chore(ui): disable debug logger --- invokeai/frontend/web/src/app/store/store.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 9397144751..ec757494f5 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -39,7 +39,6 @@ import { authToastMiddleware } from 'services/api/authToastMiddleware'; import type { JsonObject } from 'type-fest'; import { STORAGE_PREFIX } from './constants'; -import { getDebugLoggerMiddleware } from './middleware/debugLoggerMiddleware'; import { actionSanitizer } from './middleware/devtools/actionSanitizer'; import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { stateSanitizer } from './middleware/devtools/stateSanitizer'; @@ -177,7 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) - .concat(getDebugLoggerMiddleware()) + // .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); From 0eb4360c01426258b4bbaa1aacff7b6721de716c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:57:36 +1000 Subject: [PATCH 171/210] fix(ui): debounce gallery min width value --- .../web/src/features/gallery/components/NewGallery.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 2eafcefe33..39e75f0655 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -529,7 +529,8 @@ const selectGridTemplateColumns = createSelector( // Grid components const ListComponent: GridComponents['List'] = forwardRef(({ context: _, ...rest }, ref) => { - const gridTemplateColumns = useAppSelector(selectGridTemplateColumns); + const _gridTemplateColumns = useAppSelector(selectGridTemplateColumns); + const [gridTemplateColumns] = useDebounce(_gridTemplateColumns, DEBOUNCE_DELAY); return ; }); From d74d079356659f523cdffb04c1a3d7c35d72cd52 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:02:17 +1000 Subject: [PATCH 172/210] fix(ui): restore gallery selection count tag --- .../src/features/gallery/components/Gallery.tsx | 3 --- .../ImageGrid/GallerySelectionCountTag.tsx | 14 +++++--------- .../src/features/gallery/components/NewGallery.tsx | 4 +++- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 2ffcd5ca7c..2b42b64967 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -111,9 +111,6 @@ export const GalleryPanel = memo(() => { - - {/* - */} ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index 5508ad1156..5a30b8b836 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -1,20 +1,16 @@ import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useGalleryImageNames } from 'features/gallery/components/NewGallery'; -import { - selectFirstSelectedImage, - selectSelection, - selectSelectionCount, -} from 'features/gallery/store/gallerySelectors'; +import { selectFirstSelectedImage, selectSelectionCount } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; export const GallerySelectionCountTag = memo(() => { - const dispatch = useAppDispatch(); - const selection = useAppSelector(selectSelection); + const { dispatch } = useAppStore(); + const selectionCount = useAppSelector(selectSelectionCount); const { imageNames } = useGalleryImageNames(); const isGalleryFocused = useIsRegionFocused('gallery'); @@ -30,7 +26,7 @@ export const GallerySelectionCountTag = memo(() => { dependencies: [onSelectPage, isGalleryFocused], }); - if (selection.length <= 1) { + if (selectionCount <= 1) { return null; } diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 39e75f0655..284363ca9e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -29,6 +29,7 @@ import type { ImageDTO } from 'services/api/types'; import { useDebounce } from 'use-debounce'; import { GalleryImage } from './ImageGrid/GalleryImage'; +import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; const log = logger('gallery'); @@ -494,7 +495,7 @@ export const NewGallery = memo(() => { } return ( - + ref={virtuosoRef} context={context} @@ -508,6 +509,7 @@ export const NewGallery = memo(() => { scrollSeekConfiguration={scrollSeekConfiguration} rangeChanged={handleRangeChanged} /> + ); }); From e164451dfe331e56bfa9fbaab9e9c2284d46a18f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:03:45 +1000 Subject: [PATCH 173/210] chore: ruff --- invokeai/app/invocations/baseinvocation.py | 12 ++++++------ invokeai/app/invocations/segment_anything.py | 6 +++--- invokeai/app/services/config/config_default.py | 6 +++--- .../workflow_records_sqlite.py | 12 ++++++------ invokeai/backend/model_manager/merge.py | 18 +++++++++--------- .../services/download/test_download_queue.py | 12 ++++++------ ...est_flux_aitoolkit_lora_conversion_utils.py | 6 +++--- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/invokeai/app/invocations/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py index 4f2341fbcd..622d8ea60f 100644 --- a/invokeai/app/invocations/baseinvocation.py +++ b/invokeai/app/invocations/baseinvocation.py @@ -587,9 +587,9 @@ def invocation( for field_name, field_info in cls.model_fields.items(): annotation = field_info.annotation assert annotation is not None, f"{field_name} on invocation {invocation_type} has no type annotation." - assert isinstance( - field_info.json_schema_extra, dict - ), f"{field_name} on invocation {invocation_type} has a non-dict json_schema_extra, did you forget to use InputField?" + assert isinstance(field_info.json_schema_extra, dict), ( + f"{field_name} on invocation {invocation_type} has a non-dict json_schema_extra, did you forget to use InputField?" + ) original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info) @@ -712,9 +712,9 @@ def invocation_output( for field_name, field_info in cls.model_fields.items(): annotation = field_info.annotation assert annotation is not None, f"{field_name} on invocation output {output_type} has no type annotation." - assert isinstance( - field_info.json_schema_extra, dict - ), f"{field_name} on invocation output {output_type} has a non-dict json_schema_extra, did you forget to use InputField?" + assert isinstance(field_info.json_schema_extra, dict), ( + f"{field_name} on invocation output {output_type} has a non-dict json_schema_extra, did you forget to use InputField?" + ) cls._original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info) diff --git a/invokeai/app/invocations/segment_anything.py b/invokeai/app/invocations/segment_anything.py index 6b2decff18..4d82624edf 100644 --- a/invokeai/app/invocations/segment_anything.py +++ b/invokeai/app/invocations/segment_anything.py @@ -184,9 +184,9 @@ class SegmentAnythingInvocation(BaseInvocation): # Find the largest mask. return [max(masks, key=lambda x: float(x.sum()))] elif self.mask_filter == "highest_box_score": - assert ( - bounding_boxes is not None - ), "Bounding boxes must be provided to use the 'highest_box_score' mask filter." + assert bounding_boxes is not None, ( + "Bounding boxes must be provided to use the 'highest_box_score' mask filter." + ) assert len(masks) == len(bounding_boxes) # Find the index of the bounding box with the highest score. # Note that we fallback to -1.0 if the score is None. This is mainly to satisfy the type checker. In most diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 18fd5c70db..4dabac964b 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -482,9 +482,9 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig: try: # Meta is not included in the model fields, so we need to validate it separately config = InvokeAIAppConfig.model_validate(loaded_config_dict) - assert ( - config.schema_version == CONFIG_SCHEMA_VERSION - ), f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" + assert config.schema_version == CONFIG_SCHEMA_VERSION, ( + f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}" + ) return config except Exception as e: raise RuntimeError(f"Failed to load config file {config_path}: {e}") from e diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py index 367c00b503..b84b226d9f 100644 --- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -379,13 +379,13 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase): bytes_ = path.read_bytes() workflow_from_file = WorkflowValidator.validate_json(bytes_) - assert workflow_from_file.id.startswith( - "default_" - ), f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' + assert workflow_from_file.id.startswith("default_"), ( + f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}' + ) - assert ( - workflow_from_file.meta.category is WorkflowCategory.Default - ), f"Invalid default workflow category: {workflow_from_file.meta.category}" + assert workflow_from_file.meta.category is WorkflowCategory.Default, ( + f"Invalid default workflow category: {workflow_from_file.meta.category}" + ) workflows_from_file.append(workflow_from_file) diff --git a/invokeai/backend/model_manager/merge.py b/invokeai/backend/model_manager/merge.py index b00bc99f3e..03056b10f5 100644 --- a/invokeai/backend/model_manager/merge.py +++ b/invokeai/backend/model_manager/merge.py @@ -115,19 +115,19 @@ class ModelMerger(object): base_models: Set[BaseModelType] = set() variant = None if self._installer.app_config.precision == "float32" else "fp16" - assert ( - len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference - ), "When merging three models, only the 'add_difference' merge method is supported" + assert len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference, ( + "When merging three models, only the 'add_difference' merge method is supported" + ) for key in model_keys: info = store.get_model(key) model_names.append(info.name) - assert isinstance( - info, MainDiffusersConfig - ), f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" - assert info.variant == ModelVariantType( - "normal" - ), f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" + assert isinstance(info, MainDiffusersConfig), ( + f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" + ) + assert info.variant == ModelVariantType("normal"), ( + f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" + ) # tally base models used base_models.add(info.base) diff --git a/tests/app/services/download/test_download_queue.py b/tests/app/services/download/test_download_queue.py index 8feb49f999..edf3c115ac 100644 --- a/tests/app/services/download/test_download_queue.py +++ b/tests/app/services/download/test_download_queue.py @@ -211,12 +211,12 @@ def test_multifile_download(tmp_path: Path, mm2_session: Session) -> None: assert job.bytes > 0, "expected download bytes to be positive" assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes" assert job.download_path == tmp_path / "sdxl-turbo" - assert Path( - tmp_path, "sdxl-turbo/model_index.json" - ).exists(), f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" - assert Path( - tmp_path, "sdxl-turbo/text_encoder/config.json" - ).exists(), f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" + assert Path(tmp_path, "sdxl-turbo/model_index.json").exists(), ( + f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist" + ) + assert Path(tmp_path, "sdxl-turbo/text_encoder/config.json").exists(), ( + f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist" + ) assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED} queue.stop() diff --git a/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py b/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py index 1ad408861e..ed3e05a9b2 100644 --- a/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py +++ b/tests/backend/patches/lora_conversions/test_flux_aitoolkit_lora_conversion_utils.py @@ -48,9 +48,9 @@ def test_flux_aitoolkit_transformer_state_dict_is_in_invoke_format(): model_keys = set(model.state_dict().keys()) for converted_key_prefix in converted_key_prefixes: - assert any( - model_key.startswith(converted_key_prefix) for model_key in model_keys - ), f"'{converted_key_prefix}' did not match any model keys." + assert any(model_key.startswith(converted_key_prefix) for model_key in model_keys), ( + f"'{converted_key_prefix}' did not match any model keys." + ) def test_lora_model_from_flux_aitoolkit_state_dict(): From a928ed02047f240ad58efa2de6049cfc38ffa3d9 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:10:38 +1000 Subject: [PATCH 174/210] chore(ui): dpdm --- .../components/ImageGrid/GallerySearch.tsx | 2 +- .../ImageGrid/GallerySelectionCountTag.tsx | 2 +- .../gallery/components/NewGallery.tsx | 21 +++---------------- .../components/NextPrevImageButtons.tsx | 2 +- .../components/use-gallery-image-names.ts | 20 ++++++++++++++++++ 5 files changed, 26 insertions(+), 21 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx index ce18285449..3c329c90e8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx @@ -1,5 +1,5 @@ import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library'; -import { useGalleryImageNames } from 'features/gallery/components/NewGallery'; +import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names'; import type { ChangeEvent, KeyboardEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index 5a30b8b836..e299cd69bf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -1,7 +1,7 @@ import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; -import { useGalleryImageNames } from 'features/gallery/components/NewGallery'; +import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names'; import { selectFirstSelectedImage, selectSelectionCount } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 284363ca9e..35ac17beb6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,14 +1,13 @@ import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; -import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; +import type { selectListImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { LIMIT, selectGalleryImageMinimumWidth, selectImageToCompare, selectLastSelectedImage, - selectListImageNamesQueryArgs, } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; @@ -24,12 +23,13 @@ import type { VirtuosoGridHandle, } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; -import { useGetImageNamesQuery, useListImagesQuery } from 'services/api/endpoints/images'; +import { useListImagesQuery } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import { useDebounce } from 'use-debounce'; import { GalleryImage } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; +import { useGalleryImageNames } from './use-gallery-image-names'; const log = logger('gallery'); @@ -396,21 +396,6 @@ const useKeepSelectedImageInView = ( }, [imageName, imageNames, rangeRef, rootRef, virtuosoRef]); }; -const getImageNamesQueryOptions = { - selectFromResult: ({ data, isLoading, isFetching }) => ({ - imageNames: data ?? EMPTY_ARRAY, - isLoading, - isFetching, - }), -} satisfies Parameters[1]; - -export const useGalleryImageNames = () => { - const _queryArgs = useAppSelector(selectListImageNamesQueryArgs); - const [queryArgs] = useDebounce(_queryArgs, DEBOUNCE_DELAY); - const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); - return { imageNames, isLoading, isFetching, queryArgs }; -}; - const useScrollableGallery = (rootRef: RefObject) => { const [scroller, scrollerRef] = useState(null); const [initialize, osInstance] = useOverlayScrollbars({ diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx index 371d8800e2..3260e80920 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx @@ -8,7 +8,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; -import { useGalleryImageNames } from './NewGallery'; +import { useGalleryImageNames } from './use-gallery-image-names'; const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts new file mode 100644 index 0000000000..dfbe3c3775 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts @@ -0,0 +1,20 @@ +import { EMPTY_ARRAY } from 'app/store/constants'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectListImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { useGetImageNamesQuery } from 'services/api/endpoints/images'; +import { useDebounce } from 'use-debounce'; + +const getImageNamesQueryOptions = { + selectFromResult: ({ data, isLoading, isFetching }) => ({ + imageNames: data ?? EMPTY_ARRAY, + isLoading, + isFetching, + }), +} satisfies Parameters[1]; + +export const useGalleryImageNames = () => { + const _queryArgs = useAppSelector(selectListImageNamesQueryArgs); + const [queryArgs] = useDebounce(_queryArgs, 500); + const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); + return { imageNames, isLoading, isFetching, queryArgs }; +}; From 2367b9f9450a53218f4c179f0245e65fae920d54 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:14:48 +1000 Subject: [PATCH 175/210] chore: bump version to v6.0.0a7 --- 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 20405a7741..3850e89e1b 100644 --- a/invokeai/version/invokeai_version.py +++ b/invokeai/version/invokeai_version.py @@ -1 +1 @@ -__version__ = "6.0.0a6" +__version__ = "6.0.0a7" From a92ba2542cc2a7972d1fc5bf863fc9db4726ef31 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:37:44 +1000 Subject: [PATCH 176/210] feat(ui): switch to canvas tab when using launchpad --- .../SimpleSession/CanvasLaunchpadPanel.tsx | 16 ++++++++++----- .../LaunchpadAddStyleReference.tsx | 5 +++-- .../LaunchpadEditImageButton.tsx | 5 +++-- .../LaunchpadGenerateFromTextButton.tsx | 20 ++++++++++++++----- .../LaunchpadUseALayoutImageButton.tsx | 5 +++-- .../components/Core/ParamNegativePrompt.tsx | 2 +- .../components/Core/ParamPositivePrompt.tsx | 2 +- .../ParamSDXLNegativeStylePrompt.tsx | 2 +- .../ParamSDXLPositiveStylePrompt.tsx | 2 +- .../ui/layouts/auto-layout-context.tsx | 11 +++++++--- .../ui/layouts/canvas-tab-auto-layout.tsx | 8 ++++---- .../ui/layouts/generate-tab-auto-layout.tsx | 8 ++++---- .../ui/layouts/upscaling-tab-auto-layout.tsx | 8 ++++---- .../ui/layouts/workflows-tab-auto-layout.tsx | 8 ++++---- 14 files changed, 63 insertions(+), 39 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx index 2af9fb2056..f773d6e8ba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/CanvasLaunchpadPanel.tsx @@ -1,5 +1,7 @@ import { Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library'; -import { memo } from 'react'; +import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; +import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared'; +import { memo, useCallback } from 'react'; import { InitialStateMainModelPicker } from './InitialStateMainModelPicker'; import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference'; @@ -8,6 +10,10 @@ import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButt import { LaunchpadUseALayoutImageButton } from './LaunchpadUseALayoutImageButton'; export const CanvasLaunchpadPanel = memo(() => { + const ctx = useAutoLayoutContext(); + const focusCanvas = useCallback(() => { + ctx.focusPanel(WORKSPACE_PANEL_ID); + }, [ctx]); return ( @@ -24,10 +30,10 @@ export const CanvasLaunchpadPanel = memo(() => { - - - - + + + + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx index b4dfcff423..6ab253565b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference.tsx @@ -13,7 +13,7 @@ import type { ImageDTO } from 'services/api/types'; const dndTargetData = addGlobalReferenceImageDndTarget.getData(); -export const LaunchpadAddStyleReference = memo(() => { +export const LaunchpadAddStyleReference = memo((props: { extraAction?: () => void }) => { const { dispatch, getState } = useAppStore(); const uploadOptions = useMemo( @@ -23,10 +23,11 @@ export const LaunchpadAddStyleReference = memo(() => { const config = getDefaultRefImageConfig(getState); config.image = imageDTOToImageWithDims(imageDTO); dispatch(refImageAdded({ overrides: { config } })); + props.extraAction?.(); }, allowMultiple: false, }) as const, - [dispatch, getState] + [dispatch, getState, props] ); const uploadApi = useImageUploadButton(uploadOptions); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadEditImageButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadEditImageButton.tsx index f509558427..afc5c6c403 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadEditImageButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadEditImageButton.tsx @@ -13,14 +13,15 @@ const NEW_CANVAS_OPTIONS = { type: 'raster_layer', withInpaintMask: true } as co const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS); -export const LaunchpadEditImageButton = memo(() => { +export const LaunchpadEditImageButton = memo((props: { extraAction?: () => void }) => { const { getState, dispatch } = useAppStore(); const onUpload = useCallback( (imageDTO: ImageDTO) => { newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS }); + props.extraAction?.(); }, - [dispatch, getState] + [dispatch, getState, props] ); const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadGenerateFromTextButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadGenerateFromTextButton.tsx index bac9b2e750..055fb3a2ad 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadGenerateFromTextButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadGenerateFromTextButton.tsx @@ -1,19 +1,29 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { LaunchpadButton } from 'features/controlLayers/components/SimpleSession/LaunchpadButton'; -import { memo } from 'react'; +import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context'; +import { memo, useCallback } from 'react'; import { PiCursorTextBold, PiTextAaBold } from 'react-icons/pi'; -const focusOnPrompt = () => { - const promptElement = document.getElementById('prompt'); +const focusOnPrompt = (el: HTMLElement) => { + const promptElement = el.querySelector('.positive-prompt-textarea'); if (promptElement instanceof HTMLTextAreaElement) { promptElement.focus(); promptElement.select(); } }; -export const LaunchpadGenerateFromTextButton = memo(() => { +export const LaunchpadGenerateFromTextButton = memo((props: { extraAction?: () => void }) => { + const { rootRef } = useAutoLayoutContext(); + const onClick = useCallback(() => { + const el = rootRef.current; + if (!el) { + return; + } + focusOnPrompt(el); + props.extraAction?.(); + }, [props, rootRef]); return ( - + Generate from Text diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadUseALayoutImageButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadUseALayoutImageButton.tsx index 204a684abb..f05290f095 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadUseALayoutImageButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/SimpleSession/LaunchpadUseALayoutImageButton.tsx @@ -14,14 +14,15 @@ const NEW_CANVAS_OPTIONS = { type: 'control_layer', withResize: true } as const; const dndTargetData = newCanvasFromImageDndTarget.getData(NEW_CANVAS_OPTIONS); -export const LaunchpadUseALayoutImageButton = memo(() => { +export const LaunchpadUseALayoutImageButton = memo((props: { extraAction?: () => void }) => { const { getState, dispatch } = useAppStore(); const onUpload = useCallback( (imageDTO: ImageDTO) => { newCanvasFromImage({ imageDTO, getState, dispatch, ...NEW_CANVAS_OPTIONS }); + props.extraAction?.(); }, - [dispatch, getState] + [dispatch, getState, props] ); const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx index f18d1238ea..1ba98fa774 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx @@ -57,7 +57,7 @@ export const ParamNegativePrompt = memo(() => {