From cce3144c7416b8d2ac50de98a64670e56d7fe1b1 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Mon, 6 May 2024 18:29:13 +1000
Subject: [PATCH] feat(ui): add floating image viewer
---
invokeai/frontend/web/package.json | 1 +
invokeai/frontend/web/pnpm-lock.yaml | 43 +++++
invokeai/frontend/web/public/locales/en.json | 7 +-
.../frontend/web/src/app/components/App.tsx | 2 +
.../ImageViewer/CurrentImagePreview.tsx | 12 +-
.../ImageViewer/FloatingImageViewer.tsx | 178 ++++++++++++++++++
.../features/gallery/store/gallerySlice.ts | 5 +
.../web/src/features/gallery/store/types.ts | 1 +
.../src/features/ui/components/InvokeTabs.tsx | 2 +
9 files changed, 247 insertions(+), 4 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/FloatingImageViewer.tsx
diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index 96db090386..25a77cf918 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -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",
diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml
index 2e5442479f..3d688dddce 100644
--- a/invokeai/frontend/web/pnpm-lock.yaml
+++ b/invokeai/frontend/web/pnpm-lock.yaml
@@ -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:
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 97f52e5f5a..83e80e8a81 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -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",
diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx
index 30d8f41200..03c854bb48 100644
--- a/invokeai/frontend/web/src/app/components/App.tsx
+++ b/invokeai/frontend/web/src/app/components/App.tsx
@@ -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) => {
+
);
};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
index 35abf07965..840800b897 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx
@@ -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 = () => {
)}
- {shouldShowNextPrevButtons && imageDTO && (
+ {withNextPrevButtons && shouldShowNextPrevButtons && imageDTO && (
{
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const shift = useShiftModifier();
+ const rndRef = useRef(null);
+ const imagePreviewRef = useRef(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 (
+
+
+
+
+ {t('common.viewer')}
+
+
+ } size="sm" variant="link" onClick={onClose} />
+
+
+
+
+
+
+ );
+};
+
+export const FloatingImageViewer = () => {
+ const isOpen = useAppSelector((s) => s.gallery.isFloatingImageViewerOpen);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return ;
+};
+
+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 (
+ }
+ size="sm"
+ onClick={onToggle}
+ variant="link"
+ colorScheme={isOpen ? 'invokeBlue' : 'base'}
+ boxSize={8}
+ />
+ );
+};
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
index 5248977825..892c5c954d 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts
@@ -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) => {
state.isImageViewerOpen = action.payload;
},
+ isFloatingImageViewerOpenChanged: (state, action: PayloadAction) => {
+ 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(
diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts
index 0e86d2d4be..9c258060c9 100644
--- a/invokeai/frontend/web/src/features/gallery/store/types.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/types.ts
@@ -21,4 +21,5 @@ export type GalleryState = {
limit: number;
alwaysShowImageSizeBadge: boolean;
isImageViewerOpen: boolean;
+ isFloatingImageViewerOpen: boolean;
};
diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
index 42df03872c..4152f4065b 100644
--- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx
@@ -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 = () => {
+
{customNavComponent ? customNavComponent : }