tidy(ui): component organization

This commit is contained in:
psychedelicious
2025-06-04 16:30:01 +10:00
parent cd136194ad
commit 985cd8272b
22 changed files with 1227 additions and 976 deletions

View File

@@ -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 (
<CanvasManagerProviderGate>
<MenuList>
<CanvasContextMenuSelectedEntityMenuItems />
<CanvasContextMenuGlobalMenuItems />
</MenuList>
</CanvasManagerProviderGate>
);
});
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 <MenuContent />;
}, []);
return (
<FocusRegionWrapper region="canvas" sx={FOCUS_REGION_STYLES}>
<Flex
tabIndex={-1}
borderRadius="base"
position="relative"
flexDirection="column"
height="full"
width="full"
gap={2}
alignItems="center"
justifyContent="center"
overflow="hidden"
>
<CanvasManagerProviderGate>
<CanvasToolbar />
</CanvasManagerProviderGate>
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} withLongPress={false}>
{(ref) => (
<Flex ref={ref} sx={canvasBgSx} data-dynamic-grid={dynamicGrid}>
<InvokeCanvasComponent />
<CanvasManagerProviderGate>
<Flex
position="absolute"
flexDir="column"
top={1}
insetInlineStart={1}
pointerEvents="none"
gap={2}
alignItems="flex-start"
>
{showHUD && <CanvasHUD />}
<CanvasAlertsSelectedEntityStatus />
<CanvasAlertsPreserveMask />
<CanvasAlertsInvocationProgress />
</Flex>
<Flex position="absolute" top={1} insetInlineEnd={1}>
<Menu>
<MenuButton as={IconButton} icon={<PiDotsThreeOutlineVerticalFill />} colorScheme="base" />
<MenuContent />
</Menu>
</Flex>
</CanvasManagerProviderGate>
</Flex>
)}
</ContextMenu>
<Flex position="absolute" bottom={4} gap={2} align="center" justify="center">
<CanvasManagerProviderGate>
<StagingAreaIsStagingGate>
<StagingAreaToolbar />
</StagingAreaIsStagingGate>
</CanvasManagerProviderGate>
</Flex>
<Flex position="absolute" bottom={4}>
<CanvasManagerProviderGate>
<Filter />
<Transform />
<SelectObject />
</CanvasManagerProviderGate>
</Flex>
<CanvasManagerProviderGate>
<CanvasDropArea />
</CanvasManagerProviderGate>
</Flex>
</FocusRegionWrapper>
);
});
AdvancedSession.displayName = 'AdvancedSession';

View File

@@ -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<Param0<typeof useImageUploadButton>>(
() => ({
onUpload: (imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, type: 'control_layer', withResize: true, getState, dispatch });
},
allowMultiple: false,
}),
[dispatch, getState]
);
const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
const components = useMemo(
() => ({
UploadButton: (
<Button
size="sm"
variant="link"
color="base.300"
{...uploadApi.getUploadButtonProps()}
rightIcon={<PiUploadBold />}
/>
),
}),
[uploadApi]
);
return (
<Flex position="relative" flexDir="column">
<Text fontSize="lg" fontWeight="semibold">
Generate with a Control Image
</Text>
<Text color="base.300">
Generate a new image using the control image to guide the structure and composition (Text to Image with
Control).
</Text>
<Text color="base.300">
<Trans i18nKey="controlLayers.uploadOrDragAnImage" components={components} />
<input {...uploadApi.getUploadInputProps()} />
</Text>
<DndDropTarget
dndTarget={newCanvasFromImageDndTarget}
dndTargetData={generateWithControlImageDndTargetData}
label="Drop"
/>
</Flex>
);
});
GenerateWithControlImage.displayName = 'GenerateWithControlImage';

View File

@@ -0,0 +1,65 @@
/* 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 generateWithStartingImageDndTargetData = newCanvasFromImageDndTarget.getData({
type: 'raster_layer',
withResize: true,
});
export const GenerateWithStartingImage = memo(() => {
const { getState, dispatch } = useAppStore();
const useImageUploadButtonOptions = useMemo<Param0<typeof useImageUploadButton>>(
() => ({
onUpload: (imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, type: 'raster_layer', withResize: true, getState, dispatch });
},
allowMultiple: false,
}),
[dispatch, getState]
);
const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
const components = useMemo(
() => ({
UploadButton: (
<Button
size="sm"
variant="link"
color="base.300"
{...uploadApi.getUploadButtonProps()}
rightIcon={<PiUploadBold />}
/>
),
}),
[uploadApi]
);
return (
<Flex position="relative" flexDir="column">
<Text fontSize="lg" fontWeight="semibold">
Generate with a Starting Image
</Text>
<Text color="base.300">Regenerate the starting image using the model (Image to Image).</Text>
<Text color="base.300">
<Trans i18nKey="controlLayers.uploadOrDragAnImage" components={components} />
<input {...uploadApi.getUploadInputProps()} />
</Text>
<DndDropTarget
dndTarget={newCanvasFromImageDndTarget}
dndTargetData={generateWithStartingImageDndTargetData}
label="Drop"
/>
</Flex>
);
});
GenerateWithStartingImage.displayName = 'GenerateWithStartingImage';

View File

@@ -0,0 +1,65 @@
/* 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 generateWithStartingImageAndInpaintMaskDndTargetData = newCanvasFromImageDndTarget.getData({
type: 'raster_layer',
withInpaintMask: true,
});
export const GenerateWithStartingImageAndInpaintMask = memo(() => {
const { getState, dispatch } = useAppStore();
const useImageUploadButtonOptions = useMemo<Param0<typeof useImageUploadButton>>(
() => ({
onUpload: (imageDTO: ImageDTO) => {
newCanvasFromImage({ imageDTO, type: 'raster_layer', withInpaintMask: true, getState, dispatch });
},
allowMultiple: false,
}),
[dispatch, getState]
);
const uploadApi = useImageUploadButton(useImageUploadButtonOptions);
const components = useMemo(
() => ({
UploadButton: (
<Button
size="sm"
variant="link"
color="base.300"
{...uploadApi.getUploadButtonProps()}
rightIcon={<PiUploadBold />}
/>
),
}),
[uploadApi]
);
return (
<Flex position="relative" flexDir="column">
<Text fontSize="lg" fontWeight="semibold">
Edit Image
</Text>
<Text color="base.300">Edit the image by regenerating parts of it (Inpaint).</Text>
<Text color="base.300">
<Trans i18nKey="controlLayers.uploadOrDragAnImage" components={components} />
<input {...uploadApi.getUploadInputProps()} />
</Text>
<DndDropTarget
dndTarget={newCanvasFromImageDndTarget}
dndTargetData={generateWithStartingImageAndInpaintMaskDndTargetData}
label="Drop"
/>
</Flex>
);
});
GenerateWithStartingImageAndInpaintMask.displayName = 'GenerateWithStartingImageAndInpaintMask';

View File

@@ -0,0 +1,32 @@
/* eslint-disable i18next/no-literal-string */
import { Button, Flex, Heading, Text } from '@invoke-ai/ui-library';
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 { memo, useCallback } from 'react';
export const NoSession = memo(() => {
const dispatch = useAppDispatch();
const newSesh = useCallback(() => {
dispatch(canvasSessionStarted({ sessionType: 'advanced' }));
}, [dispatch]);
return (
<Flex flexDir="column" w="full" h="full" alignItems="center" justifyContent="center">
<Heading>Get Started with Invoke</Heading>
<Button variant="ghost" onClick={newSesh}>
Start a new Canvas Session
</Button>
<Text>or</Text>
<Flex flexDir="column" maxW={512}>
<GenerateWithStartingImage />
<GenerateWithControlImage />
<GenerateWithStartingImageAndInpaintMask />
</Flex>
</Flex>
);
});
NoSession.displayName = 'NoSession';

View File

@@ -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 (
<ButtonGroup isAttached={false} size="sm" {...rest}>
<Button onClick={vary} tooltip="Vary the image using Image to Image">
Vary
</Button>
<Button onClick={useAsControl} tooltip="Use this image to control a new Text to Image generation">
Use as Control
</Button>
<Button onClick={edit} tooltip="Edit parts of this image with Inpainting">
Edit
</Button>
</ButtonGroup>
);
});
ImageActions.displayName = 'ImageActions';

View File

@@ -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 (
<Tooltip label={getProgressMessage(progressEvent)}>
<CircularProgress
size="14px"
color="invokeBlue.500"
thickness={14}
isIndeterminate={!progressEvent || progressEvent.percentage === null}
value={progressEvent?.percentage ? progressEvent.percentage * 100 : undefined}
sx={circleStyles}
{...rest}
/>
</Tooltip>
);
}
);
QueueItemCircularProgress.displayName = 'QueueItemCircularProgress';

View File

@@ -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 <Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>{`#${number}`}</Text>;
});
QueueItemNumber.displayName = 'QueueItemNumber';

View File

@@ -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 (
<Flex id={getQueueItemElementId(item.item_id)} sx={sx}>
<QueueItemStatusLabel status={item.status} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} onLoad={onLoad} />}
{!imageLoaded && <QueueItemProgressImage session_id={item.session_id} position="absolute" />}
{imageDTO && imageLoaded && <ImageActions imageDTO={imageDTO} position="absolute" top={1} right={2} />}
<QueueItemNumber number={number} position="absolute" top={1} left={2} />
<QueueItemProgressMessage
session_id={item.session_id}
status={item.status}
position="absolute"
bottom={1}
left={2}
/>
<QueueItemCircularProgress
session_id={item.session_id}
status={item.status}
position="absolute"
top={1}
right={2}
/>
</Flex>
);
});
QueueItemPreviewFull.displayName = 'QueueItemPreviewFull';

View File

@@ -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 (
<Flex
id={getQueueItemElementId(item.item_id)}
sx={sx}
data-selected={isSelected}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
<QueueItemStatusLabel status={item.status} position="absolute" margin="auto" />
{imageDTO && <DndImage imageDTO={imageDTO} asThumbnail onLoad={onLoad} />}
{!imageLoaded && <QueueItemProgressImage session_id={item.session_id} position="absolute" />}
<QueueItemNumber number={number} position="absolute" top={0} left={1} />
<QueueItemCircularProgress
session_id={item.session_id}
status={item.status}
position="absolute"
top={1}
right={2}
/>
</Flex>
);
});
QueueItemPreviewMini.displayName = 'QueueItemPreviewMini';

View File

@@ -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 (
<Image
objectFit="contain"
maxH="full"
maxW="full"
draggable={false}
src={progressImage.dataURL}
width={progressImage.width}
height={progressImage.height}
{...rest}
/>
);
});
QueueItemProgressImage.displayName = 'QueueItemProgressImage';

View File

@@ -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 (
<Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>
Waiting to start...
</Text>
);
}
return (
<Text pointerEvents="none" userSelect="none" filter={DROP_SHADOW} {...rest}>
{getProgressMessage(progressEvent)}
</Text>
);
}
);
QueueItemProgressMessage.displayName = 'QueueItemProgressMessage';

View File

@@ -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 (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="base.300" {...rest}>
Pending
</Text>
);
}
if (status === 'canceled') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="warning.300" {...rest}>
Canceled
</Text>
);
}
if (status === 'failed') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="error.300" {...rest}>
Failed
</Text>
);
}
if (status === 'in_progress') {
return (
<Text pointerEvents="none" userSelect="none" fontWeight="semibold" color="invokeBlue.300" {...rest}>
In Progress
</Text>
);
}
return null;
}
);
QueueItemStatusLabel.displayName = 'QueueItemStatusLabel';

View File

@@ -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 (
<CanvasSessionContextProvider value={ctx}>
<StagingArea />
</CanvasSessionContextProvider>
);
});
SimpleSession.displayName = 'SimpleSession';

View File

@@ -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<typeof useListAllQueueItemsQuery>[1];
export const StagingArea = memo(() => {
const ctx = useCanvasSessionContext();
const [selectedItemId, setSelectedItemId] = useState<number | null>(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 (
<Flex flexDir="column" gap={2} w="full" h="full" minW={0} minH={0}>
<StagingAreaHeader autoSwitch={autoSwitch} setAutoSwitch={setAutoSwitch} />
<Divider />
{items.length > 0 && (
<StagingAreaContent
items={items}
selectedItem={selectedItem}
selectedItemId={selectedItemId}
selectedItemIndex={selectedItemIndex}
onChangeAutoSwitch={setAutoSwitch}
onSelectItemId={onSelectItemId}
/>
)}
{items.length === 0 && (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text>No generations</Text>
</Flex>
)}
</Flex>
);
});
StagingArea.displayName = 'StagingArea';

View File

@@ -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 (
<>
<Flex position="relative" w="full" h="full" maxH="full" alignItems="center" justifyContent="center" minH={0}>
{selectedItem && selectedItemIndex !== null && (
<QueueItemPreviewFull
key={`${selectedItem.item_id}-full`}
item={selectedItem}
number={selectedItemIndex + 1}
/>
)}
{!selectedItem && <Text>No generation selected</Text>}
</Flex>
<Divider />
<Flex position="relative" maxW="full" w="full" h={108}>
<ScrollableContent overflowX="scroll" overflowY="hidden">
<Flex gap={2} w="full" h="full">
{items.map((item, i) => (
<QueueItemPreviewMini
key={`${item.item_id}-mini`}
item={item}
number={i + 1}
isSelected={selectedItemId === item.item_id}
onSelectItemId={onSelectItemId}
onChangeAutoSwitch={onChangeAutoSwitch}
/>
))}
</Flex>
</ScrollableContent>
</Flex>
</>
);
}
);
StagingAreaContent.displayName = 'StagingAreaContent';

View File

@@ -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<HTMLInputElement>) => {
setAutoSwitch(e.target.checked);
},
[setAutoSwitch]
);
return (
<Flex gap={2} w="full" alignItems="center">
<Text fontSize="lg" fontWeight="bold">
Generations
</Text>
<Spacer />
<FormControl w="min-content">
<FormLabel m={0}>Auto-switch</FormLabel>
<Switch size="sm" isChecked={autoSwitch} onChange={onChangeAutoSwitch} />
</FormControl>
<Button size="sm" variant="ghost" onClick={startOver}>
Start Over
</Button>
</Flex>
);
}
);
StagingAreaHeader.displayName = 'StagingAreaHeader';

View File

@@ -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<Record<string, ProgressData>>({});
export const useProgressData = (
$progressData: WritableAtom<Record<string, ProgressData>>,
sessionId: string
): ProgressData => {
const [value, setValue] = useState<ProgressData>(() => {
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<Record<string, ProgressData>>,
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<Record<string, ProgressData>>,
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<Record<string, ProgressData>>, 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<Record<string, ProgressData>>, 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<Record<string, ProgressData>>;
};
const CanvasSessionContext = createContext<CanvasSessionContextValue | null>(null);
export const CanvasSessionContextProvider = memo(
({ value, children }: PropsWithChildren<{ value: CanvasSessionContextValue }>) => (
<CanvasSessionContext.Provider value={value}>{children}</CanvasSessionContext.Provider>
)
);
CanvasSessionContextProvider.displayName = 'CanvasSessionContextProvider';
export const useCanvasSessionContext = () => {
const ctx = useContext(CanvasSessionContext);
assert(ctx !== null, "'useCanvasSessionContext' must be used within a CanvasSessionContextProvider");
return ctx;
};

View File

@@ -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;
};

View File

@@ -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 });
};

View File

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