mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
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:
parent
209ddc2037
commit
c9886796f6
@ -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": {
|
||||
|
@ -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';
|
@ -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'],
|
||||
};
|
||||
|
@ -20,4 +20,5 @@ export type GalleryState = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
alwaysShowImageSizeBadge: boolean;
|
||||
isImageViewerOpen: boolean;
|
||||
};
|
||||
|
@ -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';
|
||||
|
||||
/**
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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 />
|
||||
|
@ -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]
|
||||
|
@ -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'];
|
||||
|
@ -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">
|
||||
|
@ -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"
|
||||
|
@ -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';
|
||||
@ -9,20 +10,23 @@ const NodesTab = () => {
|
||||
const mode = useAppSelector((s) => s.workflow.mode);
|
||||
|
||||
if (mode === 'edit') {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<NodeEditor />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
|
||||
<Flex w="full" h="full">
|
||||
<CurrentImageDisplay />
|
||||
</Flex>
|
||||
<ReactFlowProvider>
|
||||
<NodeEditor />
|
||||
</ReactFlowProvider>
|
||||
<ImageViewer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box layerStyle="first" position="relative" w="full" h="full" p={2} borderRadius="base">
|
||||
<Flex w="full" h="full">
|
||||
<CurrentImageDisplay />
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NodesTab);
|
||||
|
@ -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>
|
||||
<ControlLayersEditor />
|
||||
<ImageViewer />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -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')} />
|
||||
)}
|
||||
|
@ -1,3 +0,0 @@
|
||||
export const tabMap = ['txt2img', 'img2img', 'unifiedCanvas', 'nodes', 'modelManager', 'queue'] as const;
|
||||
|
||||
export type InvokeTabName = (typeof tabMap)[number];
|
3
invokeai/frontend/web/src/features/ui/store/tabMap.tsx
Normal file
3
invokeai/frontend/web/src/features/ui/store/tabMap.tsx
Normal 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];
|
@ -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;
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user