feat(ui): add floating image viewer

This commit is contained in:
psychedelicious 2024-05-06 18:29:13 +10:00 committed by Kent Keirsey
parent aab152a7e9
commit cce3144c74
9 changed files with 247 additions and 4 deletions

View File

@ -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",

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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