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] 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;