feat(ui): add image viewer overlay

- Works on txt2img, canvas and workflows tabs, img2img has its own side-by-side view
- In workflow editor, the is closeable only if you are in edit mode, else it's always there
- Press `i` to open
- Press `esc` to close
- Selecting an image or changing image selection opens the viewer
- When generating, if auto-switch to new image is enabled, the viewer opens when an image comes in

To support this change, I organized and restructured some tab stuff.
This commit is contained in:
psychedelicious 2024-05-02 22:29:00 +10:00 committed by Kent Keirsey
parent 209ddc2037
commit c9886796f6
18 changed files with 164 additions and 63 deletions

View File

@ -361,7 +361,8 @@
"bulkDownloadRequestFailed": "Problem Preparing Download",
"bulkDownloadFailed": "Download Failed",
"problemDeletingImages": "Problem Deleting Images",
"problemDeletingImagesDesc": "One or more images could not be deleted"
"problemDeletingImagesDesc": "One or more images could not be deleted",
"backToEditor": "Back to {{tab}} (Esc)"
},
"hotkeys": {
"searchHotkeys": "Search Hotkeys",
@ -584,6 +585,14 @@
"upscale": {
"desc": "Upscale the current image",
"title": "Upscale"
},
"backToEditor": {
"desc": "Closes the Image Viewer and shows the Editor View (Text to Image tab only)",
"title": "Back to Editor"
},
"openImageViewer": {
"desc": "Opens the Image Viewer (Text to Image tab only)",
"title": "Open Image Viewer"
}
},
"metadata": {

View File

@ -0,0 +1,80 @@
import { Button, Flex } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import CurrentImageButtons from 'features/gallery/components/CurrentImage/CurrentImageButtons';
import CurrentImagePreview from 'features/gallery/components/CurrentImage/CurrentImagePreview';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowLeftBold } from 'react-icons/pi';
const TAB_NAME_TO_TKEY: Record<InvokeTabName, string> = {
txt2img: 'common.txt2img',
img2img: 'common.img2img',
unifiedCanvas: 'common.unifiedCanvas',
nodes: 'common.nodes',
modelManager: 'modelManager.modelManager',
queue: 'queue.queue',
};
export const ImageViewer = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
const activeTabName = useAppSelector(activeTabNameSelector);
const activeTabLabel = useMemo(
() => t('gallery.backToEditor', { tab: t(TAB_NAME_TO_TKEY[activeTabName]) }),
[t, activeTabName]
);
const onClose = useCallback(() => {
dispatch(isImageViewerOpenChanged(false));
}, [dispatch]);
const onOpen = useCallback(() => {
dispatch(isImageViewerOpenChanged(true));
}, [dispatch]);
useHotkeys('esc', onClose, { enabled: isOpen }, [isOpen]);
useHotkeys('i', onOpen, { enabled: !isOpen }, [isOpen]);
if (!isOpen) {
return null;
}
return (
<Flex
layerStyle="first"
borderRadius="base"
position="absolute"
flexDirection="column"
top={0}
right={0}
bottom={0}
left={0}
p={2}
rowGap={4}
alignItems="center"
justifyContent="center"
zIndex={10} // reactflow puts its minimap at 5, so we need to be above that
>
<CurrentImageButtons />
<CurrentImagePreview />
<Button
aria-label={activeTabLabel}
tooltip={activeTabLabel}
onClick={onClose}
leftIcon={<PiArrowLeftBold />}
position="absolute"
top={2}
insetInlineEnd={2}
>
{t('common.back')}
</Button>
</Flex>
);
});
ImageViewer.displayName = 'ImageViewer';

View File

@ -21,6 +21,7 @@ const initialGalleryState: GalleryState = {
boardSearchText: '',
limit: INITIAL_IMAGE_LIMIT,
offset: 0,
isImageViewerOpen: false,
};
export const gallerySlice = createSlice({
@ -29,9 +30,11 @@ export const gallerySlice = createSlice({
reducers: {
imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
state.selection = action.payload ? [action.payload] : [];
state.isImageViewerOpen = true;
},
selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
state.selection = uniqBy(action.payload, (i) => i.image_name);
state.isImageViewerOpen = true;
},
shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitch = action.payload;
@ -75,6 +78,9 @@ export const gallerySlice = createSlice({
alwaysShowImageSizeBadgeChanged: (state, action: PayloadAction<boolean>) => {
state.alwaysShowImageSizeBadge = action.payload;
},
isImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isImageViewerOpen = action.payload;
},
},
extraReducers: (builder) => {
builder.addMatcher(isAnyBoardDeleted, (state, action) => {
@ -112,6 +118,7 @@ export const {
boardSearchTextChanged,
moreImagesLoaded,
alwaysShowImageSizeBadgeChanged,
isImageViewerOpenChanged,
} = gallerySlice.actions;
const isAnyBoardDeleted = isAnyOf(
@ -133,5 +140,5 @@ export const galleryPersistConfig: PersistConfig<GalleryState> = {
name: gallerySlice.name,
initialState: initialGalleryState,
migrate: migrateGalleryState,
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit'],
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'isImageViewerOpen'],
};

View File

@ -20,4 +20,5 @@ export type GalleryState = {
offset: number;
limit: number;
alwaysShowImageSizeBadge: boolean;
isImageViewerOpen: boolean;
};

View File

@ -1,5 +1,9 @@
import type { ControlNetConfig, IPAdapterConfig, T2IAdapterConfig } from 'features/controlAdapters/store/types';
import type { ControlNetConfigV2, IPAdapterConfigV2, T2IAdapterConfigV2 } from 'features/controlLayers/util/controlAdapters';
import type {
ControlNetConfigV2,
IPAdapterConfigV2,
T2IAdapterConfigV2,
} from 'features/controlLayers/util/controlAdapters';
import type { O } from 'ts-toolbelt';
/**

View File

@ -5,7 +5,7 @@ import NodeOpacitySlider from './NodeOpacitySlider';
import ViewportControls from './ViewportControls';
const BottomLeftPanel = () => (
<Flex gap={2} position="absolute" bottom={2} insetInlineStart={2}>
<Flex gap={2} position="absolute" bottom={0} insetInlineStart={0}>
<ViewportControls />
<NodeOpacitySlider />
</Flex>

View File

@ -19,7 +19,7 @@ const MinimapPanel = () => {
const shouldShowMinimapPanel = useAppSelector((s) => s.nodes.shouldShowMinimapPanel);
return (
<Flex gap={2} position="absolute" bottom={2} insetInlineEnd={2}>
<Flex gap={2} position="absolute" bottom={0} insetInlineEnd={0}>
{shouldShowMinimapPanel && (
<ChakraMiniMap
pannable

View File

@ -11,7 +11,7 @@ import { memo } from 'react';
const TopCenterPanel = () => {
const name = useAppSelector((s) => s.workflow.name);
return (
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex gap={2} top={0} left={0} right={0} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex gap="2">
<AddNodeButton />
<UpdateNodesButton />

View File

@ -140,6 +140,16 @@ export const useHotkeyData = (): HotkeyGroup[] => {
desc: t('hotkeys.nextImage.desc'),
hotkeys: [['Arrow Right']],
},
{
title: t('hotkeys.openImageViewer.title'),
desc: t('hotkeys.openImageViewer.desc'),
hotkeys: [['I']],
},
{
title: t('hotkeys.backToEditor.title'),
desc: t('hotkeys.backToEditor.desc'),
hotkeys: [['Esc']],
},
],
}),
[t]

View File

@ -12,10 +12,17 @@ import { selectConfigSlice } from 'features/system/store/configSlice';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons';
import ParametersPanelTextToImage from 'features/ui/components/ParametersPanelTextToImage';
import ImageToImageTab from 'features/ui/components/tabs/ImageToImageTab';
import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
import NodesTab from 'features/ui/components/tabs/NodesTab';
import QueueTab from 'features/ui/components/tabs/QueueTab';
import TextToImageTab from 'features/ui/components/tabs/TextToImageTab';
import UnifiedCanvasTab from 'features/ui/components/tabs/UnifiedCanvasTab';
import type { UsePanelOptions } from 'features/ui/hooks/usePanel';
import { usePanel } from 'features/ui/hooks/usePanel';
import { usePanelStorage } from 'features/ui/hooks/usePanelStorage';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { TAB_NUMBER_MAP } from 'features/ui/store/tabMap';
import { activeTabIndexSelector, activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from 'react';
@ -28,62 +35,56 @@ import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import ParametersPanel from './ParametersPanel';
import ImageTab from './tabs/ImageToImageTab';
import ModelManagerTab from './tabs/ModelManagerTab';
import NodesTab from './tabs/NodesTab';
import QueueTab from './tabs/QueueTab';
import ResizeHandle from './tabs/ResizeHandle';
import TextToImageTab from './tabs/TextToImageTab';
import UnifiedCanvasTab from './tabs/UnifiedCanvasTab';
interface InvokeTabInfo {
type TabData = {
id: InvokeTabName;
translationKey: string;
icon: ReactElement;
content: ReactNode;
}
};
const tabs: InvokeTabInfo[] = [
{
const TAB_DATA: Record<InvokeTabName, TabData> = {
txt2img: {
id: 'txt2img',
translationKey: 'common.txt2img',
icon: <RiInputMethodLine />,
content: <TextToImageTab />,
},
{
img2img: {
id: 'img2img',
translationKey: 'common.img2img',
icon: <RiImage2Line />,
content: <ImageTab />,
content: <ImageToImageTab />,
},
{
unifiedCanvas: {
id: 'unifiedCanvas',
translationKey: 'common.unifiedCanvas',
icon: <RiBrushLine />,
content: <UnifiedCanvasTab />,
},
{
nodes: {
id: 'nodes',
translationKey: 'common.nodes',
icon: <PiFlowArrowBold />,
content: <NodesTab />,
},
{
modelManager: {
id: 'modelManager',
translationKey: 'modelManager.modelManager',
icon: <RiBox2Line />,
content: <ModelManagerTab />,
},
{
queue: {
id: 'queue',
translationKey: 'queue.queue',
icon: <RiPlayList2Fill />,
content: <QueueTab />,
},
];
};
const enabledTabsSelector = createMemoizedSelector(selectConfigSlice, (config) =>
tabs.filter((tab) => !config.disabledTabs.includes(tab.id))
TAB_NUMBER_MAP.map((tabName) => TAB_DATA[tabName]).filter((tab) => !config.disabledTabs.includes(tab.id))
);
const NO_GALLERY_PANEL_TABS: InvokeTabName[] = ['modelManager', 'queue'];

View File

@ -39,8 +39,8 @@ const ParametersPanelTextToImage = () => {
{isSDXL ? <SDXLPrompts /> : <Prompts />}
<Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
<TabList>
<Tab>{t('common.settingsLabel')}</Tab>
<Tab>{controlLayersTitle}</Tab>
<Tab flexGrow={1}>{t('common.settingsLabel')}</Tab>
<Tab flexGrow={1}>{controlLayersTitle}</Tab>
</TabList>
<TabPanels w="full" h="full">

View File

@ -29,7 +29,7 @@ const ImageToImageTab = () => {
const panelStorage = usePanelStorage();
return (
<Box w="full" h="full">
<Box position="relative" w="full" h="full">
<PanelGroup
ref={panelGroupRef}
autoSaveId="imageTab.content"

View File

@ -1,6 +1,7 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
import { ImageViewer } from 'features/gallery/components/ImageViewer';
import NodeEditor from 'features/nodes/components/NodeEditor';
import { memo } from 'react';
import { ReactFlowProvider } from 'reactflow';
@ -10,11 +11,15 @@ const NodesTab = () => {
if (mode === 'edit') {
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<ReactFlowProvider>
<NodeEditor />
</ReactFlowProvider>
<ImageViewer />
</Box>
);
} else {
}
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<Flex w="full" h="full">
@ -22,7 +27,6 @@ const NodesTab = () => {
</Flex>
</Box>
);
}
};
export default memo(NodesTab);

View File

@ -1,31 +1,13 @@
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { Box } from '@invoke-ai/ui-library';
import { ControlLayersEditor } from 'features/controlLayers/components/ControlLayersEditor';
import { useControlLayersTitle } from 'features/controlLayers/hooks/useControlLayersTitle';
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
import { ImageViewer } from 'features/gallery/components/ImageViewer';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const TextToImageTab = () => {
const { t } = useTranslation();
const controlLayersTitle = useControlLayersTitle();
return (
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
<Tabs variant="line" isLazy={true} display="flex" flexDir="column" w="full" h="full">
<TabList>
<Tab>{t('common.viewer')}</Tab>
<Tab>{controlLayersTitle}</Tab>
</TabList>
<TabPanels w="full" h="full" minH={0} minW={0}>
<TabPanel>
<CurrentImageDisplay />
</TabPanel>
<TabPanel>
<ControlLayersEditor />
</TabPanel>
</TabPanels>
</Tabs>
<ImageViewer />
</Box>
);
};

View File

@ -6,6 +6,7 @@ import { CANVAS_TAB_TESTID } from 'features/canvas/store/constants';
import { useDroppableTypesafe } from 'features/dnd/hooks/typesafeHooks';
import type { CanvasInitialImageDropData } from 'features/dnd/types';
import { isValidDrop } from 'features/dnd/util/isValidDrop';
import { ImageViewer } from 'features/gallery/components/ImageViewer';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@ -27,6 +28,7 @@ const UnifiedCanvasTab = () => {
return (
<Flex
position="relative"
layerStyle="first"
ref={setDroppableRef}
flexDirection="column"
@ -40,6 +42,7 @@ const UnifiedCanvasTab = () => {
>
<IAICanvasToolbar />
<IAICanvas />
<ImageViewer />
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} label={t('toast.setCanvasInitialImage')} />
)}

View File

@ -1,3 +0,0 @@
export const tabMap = ['txt2img', 'img2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
export type InvokeTabName = (typeof tabMap)[number];

View File

@ -0,0 +1,3 @@
export const TAB_NUMBER_MAP = ['txt2img', 'img2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
export type InvokeTabName = (typeof TAB_NUMBER_MAP)[number];

View File

@ -3,7 +3,7 @@ import { selectConfigSlice } from 'features/system/store/configSlice';
import { selectUiSlice } from 'features/ui/store/uiSlice';
import { isString } from 'lodash-es';
import { tabMap } from './tabMap';
import { TAB_NUMBER_MAP } from './tabMap';
export const activeTabNameSelector = createSelector(
selectUiSlice,
@ -15,7 +15,7 @@ export const activeTabNameSelector = createSelector(
);
export const activeTabIndexSelector = createSelector(selectUiSlice, selectConfigSlice, (ui, config) => {
const tabs = tabMap.filter((t) => !config.disabledTabs.includes(t));
const tabs = TAB_NUMBER_MAP.filter((t) => !config.disabledTabs.includes(t));
const idx = tabs.indexOf(ui.activeTab);
return idx === -1 ? 0 : idx;
});