refactor(ui): rip out image viewer as modal

This commit is contained in:
psychedelicious
2025-06-20 15:27:14 +10:00
parent 33a28ad4f9
commit 241844bdef
14 changed files with 20 additions and 317 deletions

View File

@@ -11,11 +11,9 @@ import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useCloseChakraTooltipsOnDragFix } from 'common/hooks/useCloseChakraTooltipsOnDragFix';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher';
import { toggleImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useReadinessWatcher } from 'features/queue/store/readiness';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { configChanged } from 'features/system/store/configSlice';
import { selectLanguage } from 'features/system/store/systemSelectors';
import i18n from 'i18n';
@@ -72,12 +70,6 @@ export const GlobalHookIsolator = memo(
useWorkflowBuilderWatcher();
useDynamicPromptsWatcher();
useRegisteredHotkeys({
id: 'toggleViewer',
category: 'viewer',
callback: toggleImageViewer,
});
return null;
}
);

View File

@@ -11,7 +11,6 @@ import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { ImageViewerModal } from 'features/gallery/components/ImageViewer/ImageViewer';
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal';
import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal';
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
@@ -61,7 +60,6 @@ export const GlobalModalIsolator = memo(() => {
<CanvasPasteModal />
</CanvasManagerProviderGate>
<LoadWorkflowFromGraphModal />
<ImageViewerModal />
</>
);
});

View File

@@ -7,7 +7,6 @@ import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
import { paramsReset } from 'features/controlLayers/store/paramsSlice';
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { sentImageToCanvas } from 'features/gallery/store/actions';
import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers';
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
@@ -94,7 +93,6 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
store.dispatch(canvasReset());
store.dispatch(rasterLayerAdded({ overrides, isSelected: true }));
store.dispatch(sentImageToCanvas());
$imageViewer.set(false);
toast({
title: t('toast.sentToCanvas'),
status: 'info',
@@ -164,12 +162,10 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
// Go to the canvas tab, open the image viewer, and enable send-to-gallery mode
store.dispatch(paramsReset());
store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
$imageViewer.set(true);
break;
case 'canvas':
// Go to the canvas tab, close the image viewer, and disable send-to-gallery mode
store.dispatch(canvasReset());
$imageViewer.set(false);
break;
case 'workflows':
// Go to the workflows tab

View File

@@ -8,7 +8,6 @@ import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreview
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
import { firefoxDndFix } from 'features/dnd/util';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
@@ -48,9 +47,6 @@ export const DndImage = memo(
getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name),
onDragStart: () => {
setIsDragging(true);
if ($imageViewer.get()) {
$imageViewer.set(false);
}
},
onDrop: () => {
setIsDragging(false);

View File

@@ -4,7 +4,6 @@ import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/exter
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Heading } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { getStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { $focusedRegion } from 'common/hooks/focus';
@@ -12,7 +11,6 @@ import { useClientSideUpload } from 'common/hooks/useClientSideUpload';
import { setFileToPaste } from 'features/controlLayers/components/CanvasPasteModal';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import type { DndTargetState } from 'features/dnd/types';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
import { selectIsClientSideUploadEnabled } from 'features/system/store/configSlice';
import { toast } from 'features/toast/toast';
@@ -70,7 +68,6 @@ export const FullscreenDropzone = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const [dndState, setDndState] = useState<DndTargetState>('idle');
const activeTab = useAppSelector(selectActiveTab);
const isImageViewerOpen = useStore($imageViewer);
const isClientSideUploadEnabled = useAppSelector(selectIsClientSideUploadEnabled);
const clientSideUpload = useClientSideUpload();
@@ -96,13 +93,7 @@ export const FullscreenDropzone = memo(() => {
// While on the canvas tab and when pasting a single image, canvas may want to create a new layer. Let it handle
// the paste event.
const [firstImageFile] = files;
if (
focusedRegion === 'canvas' &&
!isImageViewerOpen &&
activeTab === 'canvas' &&
files.length === 1 &&
firstImageFile
) {
if (focusedRegion === 'canvas' && activeTab === 'canvas' && files.length === 1 && firstImageFile) {
setFileToPaste(firstImageFile);
return;
}
@@ -125,7 +116,7 @@ export const FullscreenDropzone = memo(() => {
uploadImages(uploadArgs);
}
},
[activeTab, isImageViewerOpen, t, isClientSideUploadEnabled, clientSideUpload]
[activeTab, t, isClientSideUploadEnabled, clientSideUpload]
);
const onPaste = useCallback(

View File

@@ -2,7 +2,6 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { newCanvasFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
@@ -16,56 +15,51 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const subMenu = useSubMenu();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusySafe();
const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiPlusBold />}>

View File

@@ -3,7 +3,6 @@ import { useAppStore } from 'app/store/nanostores/store';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { sentImageToCanvas } from 'features/gallery/store/actions';
import { createNewCanvasEntityFromImage } from 'features/imageActions/actions';
@@ -18,7 +17,6 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const subMenu = useSubMenu();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusySafe();
const onClickNewRasterLayerFromImage = useCallback(() => {
@@ -26,65 +24,60 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
const onClickNewControlLayerFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
const onClickNewInpaintMaskFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
const onClickNewRegionalGuidanceFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
const onClickNewRegionalReferenceImageFromImage = useCallback(() => {
const { dispatch, getState } = store;
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState });
dispatch(sentImageToCanvas());
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
return (
<MenuItem {...subMenu.parentMenuItemProps} icon={<PiPlusBold />}>

View File

@@ -1,5 +1,4 @@
import { IconMenuItem } from 'common/components/IconMenuItem';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,10 +7,10 @@ import { PiArrowsOutBold } from 'react-icons/pi';
export const ImageMenuItemOpenInViewer = memo(() => {
const { t } = useTranslation();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const onClick = useCallback(() => {
imageViewer.openImageInViewer(imageDTO);
}, [imageDTO, imageViewer]);
// TODO
imageDTO.image_name;
}, [imageDTO]);
return (
<IconMenuItem

View File

@@ -3,7 +3,6 @@ import { useAppStore } from 'app/store/nanostores/store';
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
@@ -14,20 +13,18 @@ export const ImageMenuItemUseAsRefImage = memo(() => {
const { t } = useTranslation();
const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const onClickNewGlobalReferenceImageFromImage = useCallback(() => {
const { dispatch, getState } = store;
const config = getDefaultRefImageConfig(getState);
config.image = imageDTOToImageWithDims(imageDTO);
dispatch(refImageAdded({ overrides: { config } }));
imageViewer.close();
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
status: 'success',
});
}, [imageDTO, imageViewer, store, t]);
}, [imageDTO, store, t]);
return (
<MenuItem icon={<PiImageBold />} onClickCapture={onClickNewGlobalReferenceImageFromImage}>

View File

@@ -15,7 +15,6 @@ import { firefoxDndFix } from 'features/dnd/util';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import type { MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
@@ -203,9 +202,6 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
);
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(() => {
// Use the atom here directly instead of the `useImageViewer` to avoid re-rendering the gallery when the viewer
// opened state changes.
$imageViewer.set(true);
store.dispatch(imageToCompareChanged(null));
}, [store]);

View File

@@ -1,5 +1,4 @@
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsOutBold } from 'react-icons/pi';
@@ -10,12 +9,12 @@ type Props = {
};
export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => {
const imageViewer = useImageViewer();
const { t } = useTranslation();
const onClick = useCallback(() => {
imageViewer.openImageInViewer(imageDTO);
}, [imageDTO, imageViewer]);
// TODO
imageDTO.image_name;
}, [imageDTO]);
return (
<DndImageIcon

View File

@@ -1,40 +1,12 @@
import { Box, Flex, IconButton, type SystemStyleObject, useOutsideClick } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors';
import { memo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { memo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useImageViewer } from './useImageViewer';
// type Props = {
// closeButton?: ReactNode;
// };
// const useFocusRegionOptions = {
// focusOnMount: true,
// };
// const FOCUS_REGION_STYLES: SystemStyleObject = {
// display: 'flex',
// width: 'full',
// height: 'full',
// position: 'absolute',
// flexDirection: 'column',
// inset: 0,
// alignItems: 'center',
// justifyContent: 'center',
// overflow: 'hidden',
// };
export const ImageViewer = memo(() => {
const lastSelectedImageName = useAppSelector(selectLastSelectedImageName);
const { data: lastSelectedImageDTO } = useGetImageDTOQuery(lastSelectedImageName ?? skipToken);
@@ -48,78 +20,3 @@ export const ImageViewer = memo(() => {
});
ImageViewer.displayName = 'ImageViewer';
const imageViewerContainerSx: SystemStyleObject = {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
transition: 'opacity 0.15s ease',
opacity: 1,
pointerEvents: 'auto',
'&[data-hidden="true"]': {
opacity: 0,
pointerEvents: 'none',
},
backdropFilter: 'blur(10px) brightness(70%)',
};
export const ImageViewerModal = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const imageViewer = useImageViewer();
useOutsideClick({
ref,
handler: imageViewer.close,
});
useHotkeys(
'esc',
imageViewer.close,
{
preventDefault: true,
enabled: imageViewer.isOpen,
},
[imageViewer.isOpen]
);
return (
<Box sx={imageViewerContainerSx} data-hidden={!imageViewer.isOpen}>
<Flex
ref={ref}
flexDir="column"
position="absolute"
bg="base.900"
borderRadius="base"
top={16}
right={16}
bottom={16}
left={16}
>
<ViewerToolbar />
<ImageViewer />
</Flex>
</Box>
);
});
ImageViewerModal.displayName = 'GatedImageViewer';
const ImageViewerCloseButton = memo(() => {
const { t } = useTranslation();
const imageViewer = useImageViewer();
useAssertSingleton('ImageViewerCloseButton');
useHotkeys('esc', imageViewer.close);
return (
<IconButton
tooltip={t('gallery.closeViewer')}
aria-label={t('gallery.closeViewer')}
icon={<PiXBold />}
variant="link"
alignSelf="stretch"
onClick={imageViewer.close}
/>
);
});
ImageViewerCloseButton.displayName = 'ImageViewerCloseButton';

View File

@@ -1,20 +1,12 @@
import { Box, Flex, IconButton, type SystemStyleObject, useOutsideClick } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview2';
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar2';
import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors';
import { memo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
import { memo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useImageViewer } from './useImageViewer';
// type Props = {
// closeButton?: ReactNode;
// };
@@ -48,78 +40,3 @@ export const ImageViewer = memo(() => {
});
ImageViewer.displayName = 'ImageViewer';
const imageViewerContainerSx: SystemStyleObject = {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
transition: 'opacity 0.15s ease',
opacity: 1,
pointerEvents: 'auto',
'&[data-hidden="true"]': {
opacity: 0,
pointerEvents: 'none',
},
backdropFilter: 'blur(10px) brightness(70%)',
};
export const ImageViewerModal = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const imageViewer = useImageViewer();
useOutsideClick({
ref,
handler: imageViewer.close,
});
useHotkeys(
'esc',
imageViewer.close,
{
preventDefault: true,
enabled: imageViewer.isOpen,
},
[imageViewer.isOpen]
);
return (
<Box sx={imageViewerContainerSx} data-hidden={!imageViewer.isOpen}>
<Flex
ref={ref}
flexDir="column"
position="absolute"
bg="base.900"
borderRadius="base"
top={16}
right={16}
bottom={16}
left={16}
>
<ViewerToolbar />
<ImageViewer />
</Flex>
</Box>
);
});
ImageViewerModal.displayName = 'GatedImageViewer';
const ImageViewerCloseButton = memo(() => {
const { t } = useTranslation();
const imageViewer = useImageViewer();
useAssertSingleton('ImageViewerCloseButton');
useHotkeys('esc', imageViewer.close);
return (
<IconButton
tooltip={t('gallery.closeViewer')}
aria-label={t('gallery.closeViewer')}
icon={<PiXBold />}
variant="link"
alignSelf="stretch"
onClick={imageViewer.close}
/>
);
});
ImageViewerCloseButton.displayName = 'ImageViewerCloseButton';

View File

@@ -1,62 +0,0 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
import { useCallback } from 'react';
import type { ImageDTO } from 'services/api/types';
/**
* There's a race condition that causes the canvas to not fit to layers on the very first app startup.
*
* The canvas stage uses a resize observer to fit the stage to the container, and on the first resize event, it also
* fits the layers to the stage. Subsequent resize events only fit the stage to the container, they do not fit layers
* to the stage.
*
* On the very first app startup (new user or after they reset all web UI state), the resizable panels library needs
* to do one extra resize as it initializes and figures out its target size. At this time, the canvas stage has already
* done its one-time fit layers to stage, so the canvas stage does not fit layers to the stage again.
*
* For the end user, this means that the bbox is not centered in the canvas stage on the very first app startup. On
* all subsequent app startups, the bbox is centered in the canvas stage.
*
* We can hack around this, thanks to the fact that the image viewer is always opened on the first app startup. By the
* time the user closes it, the resizable panels library has already done its one extra resize and the DOM layout has
* stabilized. So we can track the first time the image viewer is closed and fit the layers to the stage at that time,
* ensuring that the bbox is centered in the canvas stage on that first app startup.
*
* TODO(psyche): Figure out a better way to do handle this...
*/
let didCloseImageViewer = false;
const api = buildUseBoolean(false);
const useImageViewerState = api[0];
export const $imageViewer = api[1];
export const toggleImageViewer = () => $imageViewer.set(!$imageViewer.get());
export const useImageViewer = () => {
const dispatch = useAppDispatch();
const canvasManager = useCanvasManagerSafe();
const imageViewerState = useImageViewerState();
const close = useCallback(() => {
if (!didCloseImageViewer && canvasManager) {
didCloseImageViewer = true;
canvasManager.stage.fitLayersToStage();
}
imageViewerState.setFalse();
}, [canvasManager, imageViewerState]);
const openImageInViewer = useCallback(
(imageDTO: ImageDTO) => {
dispatch(imageToCompareChanged(null));
dispatch(imageSelected(imageDTO));
imageViewerState.setTrue();
},
[dispatch, imageViewerState]
);
return {
isOpen: imageViewerState.isTrue,
open: imageViewerState.setTrue,
close,
toggle: imageViewerState.toggle,
openImageInViewer,
};
};