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