mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): add floating image viewer
This commit is contained in:
parent
aab152a7e9
commit
cce3144c74
@ -89,6 +89,7 @@
|
||||
"react-konva": "^18.2.10",
|
||||
"react-redux": "9.1.0",
|
||||
"react-resizable-panels": "^2.0.16",
|
||||
"react-rnd": "^10.4.10",
|
||||
"react-select": "5.8.0",
|
||||
"react-use": "^17.5.0",
|
||||
"react-virtuoso": "^4.7.5",
|
||||
|
@ -122,6 +122,9 @@ dependencies:
|
||||
react-resizable-panels:
|
||||
specifier: ^2.0.16
|
||||
version: 2.0.16(react-dom@18.2.0)(react@18.2.0)
|
||||
react-rnd:
|
||||
specifier: ^10.4.10
|
||||
version: 10.4.10(react-dom@18.2.0)(react@18.2.0)
|
||||
react-select:
|
||||
specifier: 5.8.0
|
||||
version: 5.8.0(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
|
||||
@ -7385,6 +7388,11 @@ packages:
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
|
||||
/clsx@1.2.1:
|
||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/color-convert@1.9.3:
|
||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||
dependencies:
|
||||
@ -11200,6 +11208,16 @@ packages:
|
||||
unpipe: 1.0.0
|
||||
dev: true
|
||||
|
||||
/re-resizable@6.9.14(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-2UbPrpezMr6gkHKNCRA/N6QGGU237SKOZ78yMHId204A/oXWSAREAIuGZNQ9qlrJosewzcsv2CphZH3u7hC6ng==}
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-clientside-effect@1.2.6(react@18.2.0):
|
||||
resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==}
|
||||
peerDependencies:
|
||||
@ -11253,6 +11271,18 @@ packages:
|
||||
react: 18.2.0
|
||||
scheduler: 0.23.0
|
||||
|
||||
/react-draggable@4.4.6(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==}
|
||||
peerDependencies:
|
||||
react: '>= 16.3.0'
|
||||
react-dom: '>= 16.3.0'
|
||||
dependencies:
|
||||
clsx: 1.2.1
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-dropzone@14.2.3(react@18.2.0):
|
||||
resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==}
|
||||
engines: {node: '>= 10.13'}
|
||||
@ -11466,6 +11496,19 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-rnd@10.4.10(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-YjQAgEeSbNUoOXSD9ZBvIiLVizFb+bNhpDk8DbIRHA557NW02CXbwsAeOTpJQnsdhEL+NP2I+Ssrwejqcodtjg==}
|
||||
peerDependencies:
|
||||
react: '>=16.3.0'
|
||||
react-dom: '>=16.3.0'
|
||||
dependencies:
|
||||
re-resizable: 6.9.14(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-draggable: 4.4.6(react-dom@18.2.0)(react@18.2.0)
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/react-select@5.7.7(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-HhashZZJDRlfF/AKj0a0Lnfs3sRdw/46VJIRd8IbB9/Ovr74+ZIwkAdSBjSPXsFMG+u72c5xShqwLSKIJllzqw==}
|
||||
peerDependencies:
|
||||
|
@ -143,7 +143,8 @@
|
||||
"alpha": "Alpha",
|
||||
"selected": "Selected",
|
||||
"viewer": "Viewer",
|
||||
"tab": "Tab"
|
||||
"tab": "Tab",
|
||||
"close": "Close"
|
||||
},
|
||||
"controlnet": {
|
||||
"controlAdapter_one": "Control Adapter",
|
||||
@ -365,7 +366,9 @@
|
||||
"bulkDownloadFailed": "Download Failed",
|
||||
"problemDeletingImages": "Problem Deleting Images",
|
||||
"problemDeletingImagesDesc": "One or more images could not be deleted",
|
||||
"switchTo": "Switch to {{ tab }} (Z)"
|
||||
"switchTo": "Switch to {{ tab }} (Z)",
|
||||
"openFloatingViewer": "Open Floating Viewer",
|
||||
"closeFloatingViewer": "Close Floating Viewer"
|
||||
},
|
||||
"hotkeys": {
|
||||
"searchHotkeys": "Search Hotkeys",
|
||||
|
@ -12,6 +12,7 @@ import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
|
||||
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
|
||||
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
|
||||
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
|
||||
import { FloatingImageViewer } from 'features/gallery/components/ImageViewer/FloatingImageViewer';
|
||||
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
|
||||
import { configChanged } from 'features/system/store/configSlice';
|
||||
import { languageSelector } from 'features/system/store/systemSelectors';
|
||||
@ -96,6 +97,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage }: Props) => {
|
||||
<DynamicPromptsModal />
|
||||
<Toaster />
|
||||
<PreselectedImage selectedImage={selectedImage} />
|
||||
<FloatingImageViewer />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
@ -22,7 +22,13 @@ const selectLastSelectedImageName = createSelector(
|
||||
(lastSelectedImage) => lastSelectedImage?.image_name
|
||||
);
|
||||
|
||||
const CurrentImagePreview = () => {
|
||||
type Props = {
|
||||
isDragDisabled?: boolean;
|
||||
isDropDisabled?: boolean;
|
||||
withNextPrevButtons?: boolean;
|
||||
};
|
||||
|
||||
const CurrentImagePreview = ({ isDragDisabled = false, isDropDisabled = false, withNextPrevButtons = true }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
|
||||
const imageName = useAppSelector(selectLastSelectedImageName);
|
||||
@ -79,6 +85,8 @@ const CurrentImagePreview = () => {
|
||||
imageDTO={imageDTO}
|
||||
droppableData={droppableData}
|
||||
draggableData={draggableData}
|
||||
isDragDisabled={isDragDisabled}
|
||||
isDropDisabled={isDropDisabled}
|
||||
isUploadDisabled={true}
|
||||
fitContainer
|
||||
useThumbailFallback
|
||||
@ -106,7 +114,7 @@ const CurrentImagePreview = () => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{shouldShowNextPrevButtons && imageDTO && (
|
||||
{withNextPrevButtons && shouldShowNextPrevButtons && imageDTO && (
|
||||
<Box
|
||||
as={motion.div}
|
||||
key="nextPrevButtons"
|
||||
|
@ -0,0 +1,178 @@
|
||||
import { Flex, IconButton, Spacer, Text, useShiftModifier } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
||||
import { isFloatingImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiHourglassBold, PiXBold } from 'react-icons/pi';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
const defaultDim = 256;
|
||||
const maxDim = 512;
|
||||
const defaultSize = { width: defaultDim, height: defaultDim + 24 };
|
||||
const maxSize = { width: maxDim, height: maxDim + 24 };
|
||||
const rndDefault = { x: 0, y: 0, ...defaultSize };
|
||||
|
||||
const rndStyles = {
|
||||
zIndex: 11,
|
||||
};
|
||||
|
||||
const enableResizing = {
|
||||
top: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
left: false,
|
||||
topRight: false,
|
||||
bottomRight: true,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
};
|
||||
|
||||
const FloatingImageViewerComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const shift = useShiftModifier();
|
||||
const rndRef = useRef<Rnd>(null);
|
||||
const imagePreviewRef = useRef<HTMLDivElement>(null);
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(isFloatingImageViewerOpenChanged(false));
|
||||
}, [dispatch]);
|
||||
|
||||
const fitToScreen = useCallback(() => {
|
||||
if (!imagePreviewRef.current || !rndRef.current) {
|
||||
return;
|
||||
}
|
||||
const el = imagePreviewRef.current;
|
||||
const rnd = rndRef.current;
|
||||
|
||||
const { top, right, bottom, left, width, height } = el.getBoundingClientRect();
|
||||
const { innerWidth, innerHeight } = window;
|
||||
|
||||
const newPosition = rnd.getDraggablePosition();
|
||||
|
||||
if (top < 0) {
|
||||
newPosition.y = 0;
|
||||
}
|
||||
if (left < 0) {
|
||||
newPosition.x = 0;
|
||||
}
|
||||
if (bottom > innerHeight) {
|
||||
newPosition.y = innerHeight - height;
|
||||
}
|
||||
if (right > innerWidth) {
|
||||
newPosition.x = innerWidth - width;
|
||||
}
|
||||
rnd.updatePosition(newPosition);
|
||||
}, []);
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
if (!rndRef.current || !imagePreviewRef.current) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = imagePreviewRef.current.getBoundingClientRect();
|
||||
if (width === defaultSize.width && height === defaultSize.height) {
|
||||
rndRef.current.updateSize(maxSize);
|
||||
} else {
|
||||
rndRef.current.updateSize(defaultSize);
|
||||
}
|
||||
flushSync(fitToScreen);
|
||||
}, [fitToScreen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
window.addEventListener('resize', fitToScreen);
|
||||
return () => {
|
||||
window.removeEventListener('resize', fitToScreen);
|
||||
};
|
||||
}, [fitToScreen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Set the initial position
|
||||
if (!imagePreviewRef.current || !rndRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = imagePreviewRef.current.getBoundingClientRect();
|
||||
|
||||
const initialPosition = {
|
||||
// 54 = width of left-hand vertical bar of tab icons
|
||||
// 430 = width of parameters panel
|
||||
x: 54 + 430 / 2 - width / 2,
|
||||
// 16 = just a reasonable bottom padding
|
||||
y: window.innerHeight - height - 16,
|
||||
};
|
||||
|
||||
rndRef.current.updatePosition(initialPosition);
|
||||
}, [fitToScreen]);
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
ref={rndRef}
|
||||
default={rndDefault}
|
||||
bounds="window"
|
||||
lockAspectRatio={shift}
|
||||
minWidth={defaultSize.width}
|
||||
minHeight={defaultSize.height}
|
||||
maxWidth={maxSize.width}
|
||||
maxHeight={maxSize.height}
|
||||
style={rndStyles}
|
||||
enableResizing={enableResizing}
|
||||
>
|
||||
<Flex
|
||||
ref={imagePreviewRef}
|
||||
flexDir="column"
|
||||
bg="base.850"
|
||||
borderRadius="base"
|
||||
w="full"
|
||||
h="full"
|
||||
borderWidth={1}
|
||||
shadow="dark-lg"
|
||||
cursor="move"
|
||||
>
|
||||
<Flex bg="base.800" w="full" p={1} onDoubleClick={onDoubleClick}>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="base.300" ps={2}>
|
||||
{t('common.viewer')}
|
||||
</Text>
|
||||
<Spacer />
|
||||
<IconButton aria-label={t('common.close')} icon={<PiXBold />} size="sm" variant="link" onClick={onClose} />
|
||||
</Flex>
|
||||
<Flex p={2} w="full" h="full">
|
||||
<CurrentImagePreview isDragDisabled={true} isDropDisabled={true} withNextPrevButtons={false} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Rnd>
|
||||
);
|
||||
};
|
||||
|
||||
export const FloatingImageViewer = () => {
|
||||
const isOpen = useAppSelector((s) => s.gallery.isFloatingImageViewerOpen);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <FloatingImageViewerComponent />;
|
||||
};
|
||||
|
||||
export const ToggleFloatingImageViewerButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const isOpen = useAppSelector((s) => s.gallery.isFloatingImageViewerOpen);
|
||||
|
||||
const onToggle = useCallback(() => {
|
||||
dispatch(isFloatingImageViewerOpenChanged(!isOpen));
|
||||
}, [dispatch, isOpen]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={isOpen ? t('gallery.closeFloatingViewer') : t('gallery.openFloatingViewer')}
|
||||
aria-label={isOpen ? t('gallery.closeFloatingViewer') : t('gallery.openFloatingViewer')}
|
||||
icon={<PiHourglassBold fontSize={16} />}
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
variant="link"
|
||||
colorScheme={isOpen ? 'invokeBlue' : 'base'}
|
||||
boxSize={8}
|
||||
/>
|
||||
);
|
||||
};
|
@ -23,6 +23,7 @@ const initialGalleryState: GalleryState = {
|
||||
limit: INITIAL_IMAGE_LIMIT,
|
||||
offset: 0,
|
||||
isImageViewerOpen: false,
|
||||
isFloatingImageViewerOpen: false,
|
||||
};
|
||||
|
||||
export const gallerySlice = createSlice({
|
||||
@ -80,6 +81,9 @@ export const gallerySlice = createSlice({
|
||||
isImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isImageViewerOpen = action.payload;
|
||||
},
|
||||
isFloatingImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isFloatingImageViewerOpen = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(setActiveTab, (state) => {
|
||||
@ -121,6 +125,7 @@ export const {
|
||||
moreImagesLoaded,
|
||||
alwaysShowImageSizeBadgeChanged,
|
||||
isImageViewerOpenChanged,
|
||||
isFloatingImageViewerOpenChanged,
|
||||
} = gallerySlice.actions;
|
||||
|
||||
const isAnyBoardDeleted = isAnyOf(
|
||||
|
@ -21,4 +21,5 @@ export type GalleryState = {
|
||||
limit: number;
|
||||
alwaysShowImageSizeBadge: boolean;
|
||||
isImageViewerOpen: boolean;
|
||||
isFloatingImageViewerOpen: boolean;
|
||||
};
|
||||
|
@ -4,6 +4,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
|
||||
import { ToggleFloatingImageViewerButton } from 'features/gallery/components/ImageViewer/FloatingImageViewer';
|
||||
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
|
||||
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
|
||||
import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
|
||||
@ -223,6 +224,7 @@ const InvokeTabs = () => {
|
||||
</TabList>
|
||||
<Spacer />
|
||||
<StatusIndicator />
|
||||
<ToggleFloatingImageViewerButton />
|
||||
{customNavComponent ? customNavComponent : <SettingsMenu />}
|
||||
</Flex>
|
||||
<PanelGroup
|
||||
|
Loading…
Reference in New Issue
Block a user