Skip to content

Commit cce3144

Browse files
psychedelicioushipsterusername
authored andcommitted
feat(ui): add floating image viewer
1 parent aab152a commit cce3144

File tree

9 files changed

+247
-4
lines changed

9 files changed

+247
-4
lines changed

invokeai/frontend/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"react-konva": "^18.2.10",
9090
"react-redux": "9.1.0",
9191
"react-resizable-panels": "^2.0.16",
92+
"react-rnd": "^10.4.10",
9293
"react-select": "5.8.0",
9394
"react-use": "^17.5.0",
9495
"react-virtuoso": "^4.7.5",

invokeai/frontend/web/pnpm-lock.yaml

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

invokeai/frontend/web/public/locales/en.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@
143143
"alpha": "Alpha",
144144
"selected": "Selected",
145145
"viewer": "Viewer",
146-
"tab": "Tab"
146+
"tab": "Tab",
147+
"close": "Close"
147148
},
148149
"controlnet": {
149150
"controlAdapter_one": "Control Adapter",
@@ -365,7 +366,9 @@
365366
"bulkDownloadFailed": "Download Failed",
366367
"problemDeletingImages": "Problem Deleting Images",
367368
"problemDeletingImagesDesc": "One or more images could not be deleted",
368-
"switchTo": "Switch to {{ tab }} (Z)"
369+
"switchTo": "Switch to {{ tab }} (Z)",
370+
"openFloatingViewer": "Open Floating Viewer",
371+
"closeFloatingViewer": "Close Floating Viewer"
369372
},
370373
"hotkeys": {
371374
"searchHotkeys": "Search Hotkeys",

invokeai/frontend/web/src/app/components/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
1212
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
1313
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
1414
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
15+
import { FloatingImageViewer } from 'features/gallery/components/ImageViewer/FloatingImageViewer';
1516
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
1617
import { configChanged } from 'features/system/store/configSlice';
1718
import { languageSelector } from 'features/system/store/systemSelectors';
@@ -96,6 +97,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage }: Props) => {
9697
<DynamicPromptsModal />
9798
<Toaster />
9899
<PreselectedImage selectedImage={selectedImage} />
100+
<FloatingImageViewer />
99101
</ErrorBoundary>
100102
);
101103
};

invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ const selectLastSelectedImageName = createSelector(
2222
(lastSelectedImage) => lastSelectedImage?.image_name
2323
);
2424

25-
const CurrentImagePreview = () => {
25+
type Props = {
26+
isDragDisabled?: boolean;
27+
isDropDisabled?: boolean;
28+
withNextPrevButtons?: boolean;
29+
};
30+
31+
const CurrentImagePreview = ({ isDragDisabled = false, isDropDisabled = false, withNextPrevButtons = true }: Props) => {
2632
const { t } = useTranslation();
2733
const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
2834
const imageName = useAppSelector(selectLastSelectedImageName);
@@ -79,6 +85,8 @@ const CurrentImagePreview = () => {
7985
imageDTO={imageDTO}
8086
droppableData={droppableData}
8187
draggableData={draggableData}
88+
isDragDisabled={isDragDisabled}
89+
isDropDisabled={isDropDisabled}
8290
isUploadDisabled={true}
8391
fitContainer
8492
useThumbailFallback
@@ -106,7 +114,7 @@ const CurrentImagePreview = () => {
106114
)}
107115
</AnimatePresence>
108116
<AnimatePresence>
109-
{shouldShowNextPrevButtons && imageDTO && (
117+
{withNextPrevButtons && shouldShowNextPrevButtons && imageDTO && (
110118
<Box
111119
as={motion.div}
112120
key="nextPrevButtons"
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { Flex, IconButton, Spacer, Text, useShiftModifier } from '@invoke-ai/ui-library';
2+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
4+
import { isFloatingImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
5+
import { useCallback, useLayoutEffect, useRef } from 'react';
6+
import { flushSync } from 'react-dom';
7+
import { useTranslation } from 'react-i18next';
8+
import { PiHourglassBold, PiXBold } from 'react-icons/pi';
9+
import { Rnd } from 'react-rnd';
10+
11+
const defaultDim = 256;
12+
const maxDim = 512;
13+
const defaultSize = { width: defaultDim, height: defaultDim + 24 };
14+
const maxSize = { width: maxDim, height: maxDim + 24 };
15+
const rndDefault = { x: 0, y: 0, ...defaultSize };
16+
17+
const rndStyles = {
18+
zIndex: 11,
19+
};
20+
21+
const enableResizing = {
22+
top: false,
23+
right: false,
24+
bottom: false,
25+
left: false,
26+
topRight: false,
27+
bottomRight: true,
28+
bottomLeft: false,
29+
topLeft: false,
30+
};
31+
32+
const FloatingImageViewerComponent = () => {
33+
const { t } = useTranslation();
34+
const dispatch = useAppDispatch();
35+
const shift = useShiftModifier();
36+
const rndRef = useRef<Rnd>(null);
37+
const imagePreviewRef = useRef<HTMLDivElement>(null);
38+
const onClose = useCallback(() => {
39+
dispatch(isFloatingImageViewerOpenChanged(false));
40+
}, [dispatch]);
41+
42+
const fitToScreen = useCallback(() => {
43+
if (!imagePreviewRef.current || !rndRef.current) {
44+
return;
45+
}
46+
const el = imagePreviewRef.current;
47+
const rnd = rndRef.current;
48+
49+
const { top, right, bottom, left, width, height } = el.getBoundingClientRect();
50+
const { innerWidth, innerHeight } = window;
51+
52+
const newPosition = rnd.getDraggablePosition();
53+
54+
if (top < 0) {
55+
newPosition.y = 0;
56+
}
57+
if (left < 0) {
58+
newPosition.x = 0;
59+
}
60+
if (bottom > innerHeight) {
61+
newPosition.y = innerHeight - height;
62+
}
63+
if (right > innerWidth) {
64+
newPosition.x = innerWidth - width;
65+
}
66+
rnd.updatePosition(newPosition);
67+
}, []);
68+
69+
const onDoubleClick = useCallback(() => {
70+
if (!rndRef.current || !imagePreviewRef.current) {
71+
return;
72+
}
73+
const { width, height } = imagePreviewRef.current.getBoundingClientRect();
74+
if (width === defaultSize.width && height === defaultSize.height) {
75+
rndRef.current.updateSize(maxSize);
76+
} else {
77+
rndRef.current.updateSize(defaultSize);
78+
}
79+
flushSync(fitToScreen);
80+
}, [fitToScreen]);
81+
82+
useLayoutEffect(() => {
83+
window.addEventListener('resize', fitToScreen);
84+
return () => {
85+
window.removeEventListener('resize', fitToScreen);
86+
};
87+
}, [fitToScreen]);
88+
89+
useLayoutEffect(() => {
90+
// Set the initial position
91+
if (!imagePreviewRef.current || !rndRef.current) {
92+
return;
93+
}
94+
95+
const { width, height } = imagePreviewRef.current.getBoundingClientRect();
96+
97+
const initialPosition = {
98+
// 54 = width of left-hand vertical bar of tab icons
99+
// 430 = width of parameters panel
100+
x: 54 + 430 / 2 - width / 2,
101+
// 16 = just a reasonable bottom padding
102+
y: window.innerHeight - height - 16,
103+
};
104+
105+
rndRef.current.updatePosition(initialPosition);
106+
}, [fitToScreen]);
107+
108+
return (
109+
<Rnd
110+
ref={rndRef}
111+
default={rndDefault}
112+
bounds="window"
113+
lockAspectRatio={shift}
114+
minWidth={defaultSize.width}
115+
minHeight={defaultSize.height}
116+
maxWidth={maxSize.width}
117+
maxHeight={maxSize.height}
118+
style={rndStyles}
119+
enableResizing={enableResizing}
120+
>
121+
<Flex
122+
ref={imagePreviewRef}
123+
flexDir="column"
124+
bg="base.850"
125+
borderRadius="base"
126+
w="full"
127+
h="full"
128+
borderWidth={1}
129+
shadow="dark-lg"
130+
cursor="move"
131+
>
132+
<Flex bg="base.800" w="full" p={1} onDoubleClick={onDoubleClick}>
133+
<Text fontSize="sm" fontWeight="semibold" color="base.300" ps={2}>
134+
{t('common.viewer')}
135+
</Text>
136+
<Spacer />
137+
<IconButton aria-label={t('common.close')} icon={<PiXBold />} size="sm" variant="link" onClick={onClose} />
138+
</Flex>
139+
<Flex p={2} w="full" h="full">
140+
<CurrentImagePreview isDragDisabled={true} isDropDisabled={true} withNextPrevButtons={false} />
141+
</Flex>
142+
</Flex>
143+
</Rnd>
144+
);
145+
};
146+
147+
export const FloatingImageViewer = () => {
148+
const isOpen = useAppSelector((s) => s.gallery.isFloatingImageViewerOpen);
149+
150+
if (!isOpen) {
151+
return null;
152+
}
153+
154+
return <FloatingImageViewerComponent />;
155+
};
156+
157+
export const ToggleFloatingImageViewerButton = () => {
158+
const { t } = useTranslation();
159+
const dispatch = useAppDispatch();
160+
const isOpen = useAppSelector((s) => s.gallery.isFloatingImageViewerOpen);
161+
162+
const onToggle = useCallback(() => {
163+
dispatch(isFloatingImageViewerOpenChanged(!isOpen));
164+
}, [dispatch, isOpen]);
165+
166+
return (
167+
<IconButton
168+
tooltip={isOpen ? t('gallery.closeFloatingViewer') : t('gallery.openFloatingViewer')}
169+
aria-label={isOpen ? t('gallery.closeFloatingViewer') : t('gallery.openFloatingViewer')}
170+
icon={<PiHourglassBold fontSize={16} />}
171+
size="sm"
172+
onClick={onToggle}
173+
variant="link"
174+
colorScheme={isOpen ? 'invokeBlue' : 'base'}
175+
boxSize={8}
176+
/>
177+
);
178+
};

invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const initialGalleryState: GalleryState = {
2323
limit: INITIAL_IMAGE_LIMIT,
2424
offset: 0,
2525
isImageViewerOpen: false,
26+
isFloatingImageViewerOpen: false,
2627
};
2728

2829
export const gallerySlice = createSlice({
@@ -80,6 +81,9 @@ export const gallerySlice = createSlice({
8081
isImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
8182
state.isImageViewerOpen = action.payload;
8283
},
84+
isFloatingImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
85+
state.isFloatingImageViewerOpen = action.payload;
86+
},
8387
},
8488
extraReducers: (builder) => {
8589
builder.addCase(setActiveTab, (state) => {
@@ -121,6 +125,7 @@ export const {
121125
moreImagesLoaded,
122126
alwaysShowImageSizeBadgeChanged,
123127
isImageViewerOpenChanged,
128+
isFloatingImageViewerOpenChanged,
124129
} = gallerySlice.actions;
125130

126131
const isAnyBoardDeleted = isAnyOf(

invokeai/frontend/web/src/features/gallery/store/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ export type GalleryState = {
2121
limit: number;
2222
alwaysShowImageSizeBadge: boolean;
2323
isImageViewerOpen: boolean;
24+
isFloatingImageViewerOpen: boolean;
2425
};

invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
44
import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
55
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
66
import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
7+
import { ToggleFloatingImageViewerButton } from 'features/gallery/components/ImageViewer/FloatingImageViewer';
78
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
89
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
910
import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
@@ -223,6 +224,7 @@ const InvokeTabs = () => {
223224
</TabList>
224225
<Spacer />
225226
<StatusIndicator />
227+
<ToggleFloatingImageViewerButton />
226228
{customNavComponent ? customNavComponent : <SettingsMenu />}
227229
</Flex>
228230
<PanelGroup

0 commit comments

Comments
 (0)