mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): rework comparison activation, add hotkeys
This commit is contained in:
parent
3501636018
commit
0e5336d8fa
@ -386,7 +386,8 @@
|
|||||||
"sideBySide": "Side-by-Side",
|
"sideBySide": "Side-by-Side",
|
||||||
"swapImages": "Swap Images",
|
"swapImages": "Swap Images",
|
||||||
"compareOptions": "Comparison Options",
|
"compareOptions": "Comparison Options",
|
||||||
"sliderFitLabel": "Stretch second image to fit"
|
"sliderFitLabel": "Stretch second image to fit",
|
||||||
|
"exitCompare": "Exit Compare"
|
||||||
},
|
},
|
||||||
"hotkeys": {
|
"hotkeys": {
|
||||||
"searchHotkeys": "Search Hotkeys",
|
"searchHotkeys": "Search Hotkeys",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { enqueueRequested } from 'app/store/actions';
|
import { enqueueRequested } from 'app/store/actions';
|
||||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||||
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
|
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
|
||||||
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
|
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
|
||||||
import { buildGenerationTabGraph } from 'features/nodes/util/graph/generation/buildGenerationTabGraph';
|
import { buildGenerationTabGraph } from 'features/nodes/util/graph/generation/buildGenerationTabGraph';
|
||||||
import { buildGenerationTabSDXLGraph } from 'features/nodes/util/graph/generation/buildGenerationTabSDXLGraph';
|
import { buildGenerationTabSDXLGraph } from 'features/nodes/util/graph/generation/buildGenerationTabSDXLGraph';
|
||||||
@ -34,7 +34,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
|
|||||||
try {
|
try {
|
||||||
await req.unwrap();
|
await req.unwrap();
|
||||||
if (shouldShowProgressInViewer) {
|
if (shouldShowProgressInViewer) {
|
||||||
dispatch(viewerModeChanged('view'));
|
dispatch(isImageViewerOpenChanged(true));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
req.reset();
|
req.reset();
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { createAction } from '@reduxjs/toolkit';
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
|
||||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||||
import { imagesApi } from 'services/api/endpoints/images';
|
import { imagesApi } from 'services/api/endpoints/images';
|
||||||
import type { ImageDTO } from 'services/api/types';
|
import type { ImageDTO } from 'services/api/types';
|
||||||
import { imagesSelectors } from 'services/api/util';
|
import { imagesSelectors } from 'services/api/util';
|
||||||
@ -11,6 +11,7 @@ export const galleryImageClicked = createAction<{
|
|||||||
shiftKey: boolean;
|
shiftKey: boolean;
|
||||||
ctrlKey: boolean;
|
ctrlKey: boolean;
|
||||||
metaKey: boolean;
|
metaKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
}>('gallery/imageClicked');
|
}>('gallery/imageClicked');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,7 +29,7 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
|
|||||||
startAppListening({
|
startAppListening({
|
||||||
actionCreator: galleryImageClicked,
|
actionCreator: galleryImageClicked,
|
||||||
effect: async (action, { dispatch, getState }) => {
|
effect: async (action, { dispatch, getState }) => {
|
||||||
const { imageDTO, shiftKey, ctrlKey, metaKey } = action.payload;
|
const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const queryArgs = selectListImagesQueryArgs(state);
|
const queryArgs = selectListImagesQueryArgs(state);
|
||||||
const { data: listImagesData } = imagesApi.endpoints.listImages.select(queryArgs)(state);
|
const { data: listImagesData } = imagesApi.endpoints.listImages.select(queryArgs)(state);
|
||||||
@ -41,7 +42,9 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
|
|||||||
const imageDTOs = imagesSelectors.selectAll(listImagesData);
|
const imageDTOs = imagesSelectors.selectAll(listImagesData);
|
||||||
const selection = state.gallery.selection;
|
const selection = state.gallery.selection;
|
||||||
|
|
||||||
if (shiftKey) {
|
if (altKey) {
|
||||||
|
dispatch(imageToCompareChanged(imageDTO));
|
||||||
|
} else if (shiftKey) {
|
||||||
const rangeEndImageName = imageDTO.image_name;
|
const rangeEndImageName = imageDTO.image_name;
|
||||||
const lastSelectedImage = selection[selection.length - 1]?.image_name;
|
const lastSelectedImage = selection[selection.length - 1]?.image_name;
|
||||||
const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage);
|
const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage);
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
boardIdSelected,
|
boardIdSelected,
|
||||||
galleryViewChanged,
|
galleryViewChanged,
|
||||||
imageSelected,
|
imageSelected,
|
||||||
viewerModeChanged,
|
isImageViewerOpenChanged,
|
||||||
} from 'features/gallery/store/gallerySlice';
|
} from 'features/gallery/store/gallerySlice';
|
||||||
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
|
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
|
||||||
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
|
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
|
||||||
@ -108,7 +108,7 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(imageSelected(imageDTO));
|
dispatch(imageSelected(imageDTO));
|
||||||
dispatch(viewerModeChanged('view'));
|
dispatch(isImageViewerOpenChanged(true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,11 +46,7 @@ type SingleSelectionMenuItemsProps = {
|
|||||||
const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
|
const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
|
||||||
const { imageDTO } = props;
|
const { imageDTO } = props;
|
||||||
const optimalDimension = useAppSelector(selectOptimalDimension);
|
const optimalDimension = useAppSelector(selectOptimalDimension);
|
||||||
const maySelectForCompare = useAppSelector(
|
const maySelectForCompare = useAppSelector((s) => s.gallery.imageToCompare?.image_name !== imageDTO.image_name);
|
||||||
(s) =>
|
|
||||||
s.gallery.imageToCompare?.image_name !== imageDTO.image_name &&
|
|
||||||
s.gallery.selection.slice(-1)[0]?.image_name !== imageDTO.image_name
|
|
||||||
);
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCanvasEnabled = useFeatureStatus('canvas');
|
const isCanvasEnabled = useFeatureStatus('canvas');
|
||||||
|
@ -11,7 +11,7 @@ import type { GallerySelectionDraggableData, ImageDraggableData, TypesafeDraggab
|
|||||||
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
||||||
import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
|
import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
|
||||||
import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
|
import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
|
||||||
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
|
import { imageToCompareChanged, isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
|
||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -46,9 +46,7 @@ const GalleryImage = (props: HoverableImageProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
|
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
|
||||||
const alwaysShowImageSizeBadge = useAppSelector((s) => s.gallery.alwaysShowImageSizeBadge);
|
const alwaysShowImageSizeBadge = useAppSelector((s) => s.gallery.alwaysShowImageSizeBadge);
|
||||||
const isSelectedForCompare = useAppSelector(
|
const isSelectedForCompare = useAppSelector((s) => s.gallery.imageToCompare?.image_name === imageName);
|
||||||
(s) => s.gallery.imageToCompare?.image_name === imageName && s.gallery.viewerMode === 'compare'
|
|
||||||
);
|
|
||||||
const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO);
|
const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO);
|
||||||
|
|
||||||
const customStarUi = useStore($customStarUI);
|
const customStarUi = useStore($customStarUI);
|
||||||
@ -107,7 +105,8 @@ const GalleryImage = (props: HoverableImageProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onDoubleClick = useCallback(() => {
|
const onDoubleClick = useCallback(() => {
|
||||||
dispatch(viewerModeChanged('view'));
|
dispatch(isImageViewerOpenChanged(true));
|
||||||
|
dispatch(imageToCompareChanged(null));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleMouseOut = useCallback(() => {
|
const handleMouseOut = useCallback(() => {
|
||||||
|
@ -12,7 +12,12 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
} from '@invoke-ai/ui-library';
|
} from '@invoke-ai/ui-library';
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
import { comparedImagesSwapped, comparisonModeChanged, sliderFitChanged } from 'features/gallery/store/gallerySlice';
|
import {
|
||||||
|
comparedImagesSwapped,
|
||||||
|
comparisonModeChanged,
|
||||||
|
imageToCompareChanged,
|
||||||
|
sliderFitChanged,
|
||||||
|
} from 'features/gallery/store/gallerySlice';
|
||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent } from 'react';
|
||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -38,20 +43,12 @@ export const ImageComparisonToolbarButtons = memo(() => {
|
|||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
const exitCompare = useCallback(() => {
|
||||||
|
dispatch(imageToCompareChanged(null));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ButtonGroup variant="outline">
|
|
||||||
<Button onClick={setComparisonModeSlider} colorScheme={comparisonMode === 'slider' ? 'invokeBlue' : 'base'}>
|
|
||||||
{t('gallery.slider')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={setComparisonModeSideBySide}
|
|
||||||
colorScheme={comparisonMode === 'side-by-side' ? 'invokeBlue' : 'base'}
|
|
||||||
>
|
|
||||||
{t('gallery.sideBySide')}
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
<Popover isLazy>
|
<Popover isLazy>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<IconButton
|
<IconButton
|
||||||
@ -63,13 +60,25 @@ export const ImageComparisonToolbarButtons = memo(() => {
|
|||||||
<PopoverContent>
|
<PopoverContent>
|
||||||
<PopoverBody>
|
<PopoverBody>
|
||||||
<Flex direction="column" gap={2}>
|
<Flex direction="column" gap={2}>
|
||||||
<FormControl>
|
<ButtonGroup variant="outline" size="sm" w="full">
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
onClick={setComparisonModeSlider}
|
||||||
|
colorScheme={comparisonMode === 'slider' ? 'invokeBlue' : 'base'}
|
||||||
|
>
|
||||||
|
{t('gallery.slider')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
onClick={setComparisonModeSideBySide}
|
||||||
|
colorScheme={comparisonMode === 'side-by-side' ? 'invokeBlue' : 'base'}
|
||||||
|
>
|
||||||
|
{t('gallery.sideBySide')}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<FormControl isDisabled={comparisonMode !== 'slider'}>
|
||||||
<FormLabel>{t('gallery.sliderFitLabel')}</FormLabel>
|
<FormLabel>{t('gallery.sliderFitLabel')}</FormLabel>
|
||||||
<Switch
|
<Switch isChecked={sliderFit === 'fill'} onChange={onSliderFitChanged} />
|
||||||
isChecked={sliderFit === 'fill'}
|
|
||||||
isDisabled={comparisonMode !== 'slider'}
|
|
||||||
onChange={onSliderFitChanged}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Flex>
|
</Flex>
|
||||||
</PopoverBody>
|
</PopoverBody>
|
||||||
@ -77,6 +86,7 @@ export const ImageComparisonToolbarButtons = memo(() => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<Button onClick={swapImages}>{t('gallery.swapImages')}</Button>
|
<Button onClick={swapImages}>{t('gallery.swapImages')}</Button>
|
||||||
|
<Button onClick={exitCompare}>{t('gallery.exitCompare')}</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -17,18 +17,19 @@ import { ViewerToggleMenu } from './ViewerToggleMenu';
|
|||||||
const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
|
const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
|
||||||
|
|
||||||
export const ImageViewer = memo(() => {
|
export const ImageViewer = memo(() => {
|
||||||
const { viewerMode, onToggle, openEditor } = useImageViewer();
|
const { isOpen, onToggle, onClose } = useImageViewer();
|
||||||
const activeTabName = useAppSelector(activeTabNameSelector);
|
const activeTabName = useAppSelector(activeTabNameSelector);
|
||||||
|
const isComparing = useAppSelector((s) => s.gallery.imageToCompare !== null);
|
||||||
const isViewerEnabled = useMemo(() => VIEWER_ENABLED_TABS.includes(activeTabName), [activeTabName]);
|
const isViewerEnabled = useMemo(() => VIEWER_ENABLED_TABS.includes(activeTabName), [activeTabName]);
|
||||||
const shouldShowViewer = useMemo(() => {
|
const shouldShowViewer = useMemo(() => {
|
||||||
if (!isViewerEnabled) {
|
if (!isViewerEnabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return viewerMode === 'view' || viewerMode === 'compare';
|
return isOpen;
|
||||||
}, [viewerMode, isViewerEnabled]);
|
}, [isOpen, isViewerEnabled]);
|
||||||
|
|
||||||
useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
|
useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
|
||||||
useHotkeys('esc', openEditor, { enabled: isViewerEnabled }, [isViewerEnabled, openEditor]);
|
useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
|
||||||
|
|
||||||
if (!shouldShowViewer) {
|
if (!shouldShowViewer) {
|
||||||
return null;
|
return null;
|
||||||
@ -58,8 +59,8 @@ export const ImageViewer = memo(() => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex flex={1} gap={2} justifyContent="center">
|
<Flex flex={1} gap={2} justifyContent="center">
|
||||||
{viewerMode === 'view' && <CurrentImageButtons />}
|
{!isComparing && <CurrentImageButtons />}
|
||||||
{viewerMode === 'compare' && <ImageComparisonToolbarButtons />}
|
{isComparing && <ImageComparisonToolbarButtons />}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex flex={1} justifyContent="center">
|
<Flex flex={1} justifyContent="center">
|
||||||
<Flex gap={2} marginInlineStart="auto">
|
<Flex gap={2} marginInlineStart="auto">
|
||||||
@ -68,8 +69,8 @@ export const ImageViewer = memo(() => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Box w="full" h="full">
|
<Box w="full" h="full">
|
||||||
{viewerMode === 'view' && <CurrentImagePreview />}
|
{!isComparing && <CurrentImagePreview />}
|
||||||
{viewerMode === 'compare' && <ImageComparison />}
|
{isComparing && <ImageComparison />}
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
@ -8,60 +8,23 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
Text,
|
Text,
|
||||||
useDisclosure,
|
|
||||||
} from '@invoke-ai/ui-library';
|
} from '@invoke-ai/ui-library';
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PiCaretDownBold, PiCheckBold, PiEyeBold, PiImagesBold, PiPencilBold } from 'react-icons/pi';
|
import { PiCaretDownBold, PiCheckBold, PiEyeBold, PiPencilBold } from 'react-icons/pi';
|
||||||
|
|
||||||
import { useImageViewer } from './useImageViewer';
|
import { useImageViewer } from './useImageViewer';
|
||||||
|
|
||||||
export const ViewerToggleMenu = () => {
|
export const ViewerToggleMenu = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onClose, onOpen } = useImageViewer();
|
||||||
const { viewerMode, openEditor, openViewer, openCompare } = useImageViewer();
|
|
||||||
const icon = useMemo(() => {
|
|
||||||
if (viewerMode === 'view') {
|
|
||||||
return <Icon as={PiEyeBold} />;
|
|
||||||
}
|
|
||||||
if (viewerMode === 'edit') {
|
|
||||||
return <Icon as={PiPencilBold} />;
|
|
||||||
}
|
|
||||||
if (viewerMode === 'compare') {
|
|
||||||
return <Icon as={PiImagesBold} />;
|
|
||||||
}
|
|
||||||
}, [viewerMode]);
|
|
||||||
const label = useMemo(() => {
|
|
||||||
if (viewerMode === 'view') {
|
|
||||||
return t('common.viewing');
|
|
||||||
}
|
|
||||||
if (viewerMode === 'edit') {
|
|
||||||
return t('common.editing');
|
|
||||||
}
|
|
||||||
if (viewerMode === 'compare') {
|
|
||||||
return t('common.comparing');
|
|
||||||
}
|
|
||||||
}, [t, viewerMode]);
|
|
||||||
const _openEditor = useCallback(() => {
|
|
||||||
openEditor();
|
|
||||||
onClose();
|
|
||||||
}, [onClose, openEditor]);
|
|
||||||
const _openViewer = useCallback(() => {
|
|
||||||
openViewer();
|
|
||||||
onClose();
|
|
||||||
}, [onClose, openViewer]);
|
|
||||||
const _openCompare = useCallback(() => {
|
|
||||||
openCompare();
|
|
||||||
onClose();
|
|
||||||
}, [onClose, openCompare]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover isOpen={isOpen} onClose={onClose} onOpen={onOpen}>
|
<Popover isLazy>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Button variant="outline" data-testid="toggle-viewer-menu-button">
|
<Button variant="outline" data-testid="toggle-viewer-menu-button">
|
||||||
<Flex gap={3} w="full" alignItems="center">
|
<Flex gap={3} w="full" alignItems="center">
|
||||||
{icon}
|
{isOpen ? <Icon as={PiEyeBold} /> : <Icon as={PiPencilBold} />}
|
||||||
<Text fontSize="md">{label}</Text>
|
<Text fontSize="md">{isOpen ? t('common.viewing') : t('common.editing')}</Text>
|
||||||
<Icon as={PiCaretDownBold} />
|
<Icon as={PiCaretDownBold} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Button>
|
</Button>
|
||||||
@ -70,9 +33,9 @@ export const ViewerToggleMenu = () => {
|
|||||||
<PopoverArrow />
|
<PopoverArrow />
|
||||||
<PopoverBody>
|
<PopoverBody>
|
||||||
<Flex flexDir="column">
|
<Flex flexDir="column">
|
||||||
<Button onClick={_openViewer} variant="ghost" h="auto" w="auto" p={2}>
|
<Button onClick={onOpen} variant="ghost" h="auto" w="auto" p={2}>
|
||||||
<Flex gap={2} w="full">
|
<Flex gap={2} w="full">
|
||||||
<Icon as={PiCheckBold} visibility={viewerMode === 'view' ? 'visible' : 'hidden'} />
|
<Icon as={PiCheckBold} visibility={isOpen ? 'visible' : 'hidden'} />
|
||||||
<Flex flexDir="column" gap={2} alignItems="flex-start">
|
<Flex flexDir="column" gap={2} alignItems="flex-start">
|
||||||
<Text fontWeight="semibold" color="base.100">
|
<Text fontWeight="semibold" color="base.100">
|
||||||
{t('common.viewing')}
|
{t('common.viewing')}
|
||||||
@ -83,9 +46,9 @@ export const ViewerToggleMenu = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={_openEditor} variant="ghost" h="auto" w="auto" p={2}>
|
<Button onClick={onClose} variant="ghost" h="auto" w="auto" p={2}>
|
||||||
<Flex gap={2} w="full">
|
<Flex gap={2} w="full">
|
||||||
<Icon as={PiCheckBold} visibility={viewerMode === 'edit' ? 'visible' : 'hidden'} />
|
<Icon as={PiCheckBold} visibility={isOpen ? 'hidden' : 'visible'} />
|
||||||
<Flex flexDir="column" gap={2} alignItems="flex-start">
|
<Flex flexDir="column" gap={2} alignItems="flex-start">
|
||||||
<Text fontWeight="semibold" color="base.100">
|
<Text fontWeight="semibold" color="base.100">
|
||||||
{t('common.editing')}
|
{t('common.editing')}
|
||||||
@ -96,19 +59,6 @@ export const ViewerToggleMenu = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={_openCompare} variant="ghost" h="auto" w="auto" p={2}>
|
|
||||||
<Flex gap={2} w="full">
|
|
||||||
<Icon as={PiCheckBold} visibility={viewerMode === 'compare' ? 'visible' : 'hidden'} />
|
|
||||||
<Flex flexDir="column" gap={2} alignItems="flex-start">
|
|
||||||
<Text fontWeight="semibold" color="base.100">
|
|
||||||
{t('common.comparing')}
|
|
||||||
</Text>
|
|
||||||
<Text fontWeight="normal" color="base.300">
|
|
||||||
{t('common.comparingDesc')}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</PopoverBody>
|
</PopoverBody>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
@ -1,26 +1,22 @@
|
|||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
|
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export const useImageViewer = () => {
|
export const useImageViewer = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const viewerMode = useAppSelector((s) => s.gallery.viewerMode);
|
const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
|
||||||
|
|
||||||
const openEditor = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
dispatch(viewerModeChanged('edit'));
|
dispatch(isImageViewerOpenChanged(false));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const openViewer = useCallback(() => {
|
const onOpen = useCallback(() => {
|
||||||
dispatch(viewerModeChanged('view'));
|
dispatch(isImageViewerOpenChanged(true));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const onToggle = useCallback(() => {
|
const onToggle = useCallback(() => {
|
||||||
dispatch(viewerModeChanged(viewerMode === 'view' ? 'edit' : 'view'));
|
dispatch(isImageViewerOpenChanged(!isOpen));
|
||||||
}, [dispatch, viewerMode]);
|
}, [dispatch, isOpen]);
|
||||||
|
|
||||||
const openCompare = useCallback(() => {
|
return { isOpen, onOpen, onClose, onToggle };
|
||||||
dispatch(viewerModeChanged('compare'));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return { viewerMode, openEditor, openViewer, openCompare, onToggle };
|
|
||||||
};
|
};
|
||||||
|
@ -27,16 +27,16 @@ export const useGalleryHotkeys = () => {
|
|||||||
useGalleryNavigation();
|
useGalleryNavigation();
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'left',
|
['left', 'alt+left'],
|
||||||
() => {
|
(e) => {
|
||||||
canNavigateGallery && handleLeftImage();
|
canNavigateGallery && handleLeftImage(e.altKey);
|
||||||
},
|
},
|
||||||
[handleLeftImage, canNavigateGallery]
|
[handleLeftImage, canNavigateGallery]
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'right',
|
['right', 'alt+right'],
|
||||||
() => {
|
(e) => {
|
||||||
if (!canNavigateGallery) {
|
if (!canNavigateGallery) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -45,29 +45,29 @@ export const useGalleryHotkeys = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isOnLastImage) {
|
if (!isOnLastImage) {
|
||||||
handleRightImage();
|
handleRightImage(e.altKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isOnLastImage, areMoreImagesAvailable, handleLoadMoreImages, isFetching, handleRightImage, canNavigateGallery]
|
[isOnLastImage, areMoreImagesAvailable, handleLoadMoreImages, isFetching, handleRightImage, canNavigateGallery]
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'up',
|
['up', 'alt+up'],
|
||||||
() => {
|
(e) => {
|
||||||
handleUpImage();
|
handleUpImage(e.altKey);
|
||||||
},
|
},
|
||||||
{ preventDefault: true },
|
{ preventDefault: true },
|
||||||
[handleUpImage]
|
[handleUpImage]
|
||||||
);
|
);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'down',
|
['down', 'alt+down'],
|
||||||
() => {
|
(e) => {
|
||||||
if (!areImagesBelowCurrent && areMoreImagesAvailable && !isFetching) {
|
if (!areImagesBelowCurrent && areMoreImagesAvailable && !isFetching) {
|
||||||
handleLoadMoreImages();
|
handleLoadMoreImages();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleDownImage();
|
handleDownImage(e.altKey);
|
||||||
},
|
},
|
||||||
{ preventDefault: true },
|
{ preventDefault: true },
|
||||||
[areImagesBelowCurrent, areMoreImagesAvailable, handleLoadMoreImages, isFetching, handleDownImage]
|
[areImagesBelowCurrent, areMoreImagesAvailable, handleLoadMoreImages, isFetching, handleDownImage]
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
|
import { useAltModifier } from '@invoke-ai/ui-library';
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
||||||
import { imageItemContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridItemContainer';
|
import { imageItemContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridItemContainer';
|
||||||
import { imageListContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridListContainer';
|
import { imageListContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridListContainer';
|
||||||
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
|
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
|
||||||
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
|
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
|
||||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
|
||||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
|
||||||
import { getIsVisible } from 'features/gallery/util/getIsVisible';
|
import { getIsVisible } from 'features/gallery/util/getIsVisible';
|
||||||
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
|
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
@ -106,10 +106,10 @@ const getImageFuncs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UseGalleryNavigationReturn = {
|
type UseGalleryNavigationReturn = {
|
||||||
handleLeftImage: () => void;
|
handleLeftImage: (alt?: boolean) => void;
|
||||||
handleRightImage: () => void;
|
handleRightImage: (alt?: boolean) => void;
|
||||||
handleUpImage: () => void;
|
handleUpImage: (alt?: boolean) => void;
|
||||||
handleDownImage: () => void;
|
handleDownImage: (alt?: boolean) => void;
|
||||||
isOnFirstImage: boolean;
|
isOnFirstImage: boolean;
|
||||||
isOnLastImage: boolean;
|
isOnLastImage: boolean;
|
||||||
areImagesBelowCurrent: boolean;
|
areImagesBelowCurrent: boolean;
|
||||||
@ -123,7 +123,15 @@ type UseGalleryNavigationReturn = {
|
|||||||
*/
|
*/
|
||||||
export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
const alt = useAltModifier();
|
||||||
|
const lastSelectedImage = useAppSelector((s) => {
|
||||||
|
const lastSelected = s.gallery.selection.slice(-1)[0] ?? null;
|
||||||
|
if (alt) {
|
||||||
|
return s.gallery.imageToCompare ?? lastSelected;
|
||||||
|
} else {
|
||||||
|
return lastSelected;
|
||||||
|
}
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
queryResult: { data },
|
queryResult: { data },
|
||||||
} = useGalleryImages();
|
} = useGalleryImages();
|
||||||
@ -136,7 +144,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
|||||||
}, [lastSelectedImage, data]);
|
}, [lastSelectedImage, data]);
|
||||||
|
|
||||||
const handleNavigation = useCallback(
|
const handleNavigation = useCallback(
|
||||||
(direction: 'left' | 'right' | 'up' | 'down') => {
|
(direction: 'left' | 'right' | 'up' | 'down', alt?: boolean) => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -144,10 +152,14 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
|||||||
if (!image || index === lastSelectedImageIndex) {
|
if (!image || index === lastSelectedImageIndex) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch(imageSelected(image));
|
if (alt) {
|
||||||
|
dispatch(imageToCompareChanged(image));
|
||||||
|
} else {
|
||||||
|
dispatch(imageSelected(image));
|
||||||
|
}
|
||||||
scrollToImage(image.image_name, index);
|
scrollToImage(image.image_name, index);
|
||||||
},
|
},
|
||||||
[dispatch, lastSelectedImageIndex, data]
|
[data, lastSelectedImageIndex, dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOnFirstImage = useMemo(() => lastSelectedImageIndex === 0, [lastSelectedImageIndex]);
|
const isOnFirstImage = useMemo(() => lastSelectedImageIndex === 0, [lastSelectedImageIndex]);
|
||||||
@ -162,21 +174,33 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
|||||||
return lastSelectedImageIndex + imagesPerRow < loadedImagesCount;
|
return lastSelectedImageIndex + imagesPerRow < loadedImagesCount;
|
||||||
}, [lastSelectedImageIndex, loadedImagesCount]);
|
}, [lastSelectedImageIndex, loadedImagesCount]);
|
||||||
|
|
||||||
const handleLeftImage = useCallback(() => {
|
const handleLeftImage = useCallback(
|
||||||
handleNavigation('left');
|
(alt?: boolean) => {
|
||||||
}, [handleNavigation]);
|
handleNavigation('left', alt);
|
||||||
|
},
|
||||||
|
[handleNavigation]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRightImage = useCallback(() => {
|
const handleRightImage = useCallback(
|
||||||
handleNavigation('right');
|
(alt?: boolean) => {
|
||||||
}, [handleNavigation]);
|
handleNavigation('right', alt);
|
||||||
|
},
|
||||||
|
[handleNavigation]
|
||||||
|
);
|
||||||
|
|
||||||
const handleUpImage = useCallback(() => {
|
const handleUpImage = useCallback(
|
||||||
handleNavigation('up');
|
(alt?: boolean) => {
|
||||||
}, [handleNavigation]);
|
handleNavigation('up', alt);
|
||||||
|
},
|
||||||
|
[handleNavigation]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDownImage = useCallback(() => {
|
const handleDownImage = useCallback(
|
||||||
handleNavigation('down');
|
(alt?: boolean) => {
|
||||||
}, [handleNavigation]);
|
handleNavigation('down', alt);
|
||||||
|
},
|
||||||
|
[handleNavigation]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleLeftImage,
|
handleLeftImage,
|
||||||
|
@ -36,6 +36,7 @@ export const useMultiselect = (imageDTO?: ImageDTO) => {
|
|||||||
shiftKey: e.shiftKey,
|
shiftKey: e.shiftKey,
|
||||||
ctrlKey: e.ctrlKey,
|
ctrlKey: e.ctrlKey,
|
||||||
metaKey: e.metaKey,
|
metaKey: e.metaKey,
|
||||||
|
altKey: e.altKey,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -6,7 +6,7 @@ import { boardsApi } from 'services/api/endpoints/boards';
|
|||||||
import { imagesApi } from 'services/api/endpoints/images';
|
import { imagesApi } from 'services/api/endpoints/images';
|
||||||
import type { ImageDTO } from 'services/api/types';
|
import type { ImageDTO } from 'services/api/types';
|
||||||
|
|
||||||
import type { BoardId, ComparisonMode, GalleryState, GalleryView, ViewerMode } from './types';
|
import type { BoardId, ComparisonMode, GalleryState, GalleryView } from './types';
|
||||||
import { IMAGE_LIMIT, INITIAL_IMAGE_LIMIT } from './types';
|
import { IMAGE_LIMIT, INITIAL_IMAGE_LIMIT } from './types';
|
||||||
|
|
||||||
const initialGalleryState: GalleryState = {
|
const initialGalleryState: GalleryState = {
|
||||||
@ -21,7 +21,7 @@ const initialGalleryState: GalleryState = {
|
|||||||
boardSearchText: '',
|
boardSearchText: '',
|
||||||
limit: INITIAL_IMAGE_LIMIT,
|
limit: INITIAL_IMAGE_LIMIT,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
viewerMode: 'view',
|
isImageViewerOpen: true,
|
||||||
imageToCompare: null,
|
imageToCompare: null,
|
||||||
comparisonMode: 'slider',
|
comparisonMode: 'slider',
|
||||||
sliderFit: 'fill',
|
sliderFit: 'fill',
|
||||||
@ -40,7 +40,7 @@ export const gallerySlice = createSlice({
|
|||||||
imageToCompareChanged: (state, action: PayloadAction<ImageDTO | null>) => {
|
imageToCompareChanged: (state, action: PayloadAction<ImageDTO | null>) => {
|
||||||
state.imageToCompare = action.payload;
|
state.imageToCompare = action.payload;
|
||||||
if (action.payload) {
|
if (action.payload) {
|
||||||
state.viewerMode = 'compare';
|
state.isImageViewerOpen = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
comparisonModeChanged: (state, action: PayloadAction<ComparisonMode>) => {
|
comparisonModeChanged: (state, action: PayloadAction<ComparisonMode>) => {
|
||||||
@ -88,8 +88,12 @@ export const gallerySlice = createSlice({
|
|||||||
alwaysShowImageSizeBadgeChanged: (state, action: PayloadAction<boolean>) => {
|
alwaysShowImageSizeBadgeChanged: (state, action: PayloadAction<boolean>) => {
|
||||||
state.alwaysShowImageSizeBadge = action.payload;
|
state.alwaysShowImageSizeBadge = action.payload;
|
||||||
},
|
},
|
||||||
viewerModeChanged: (state, action: PayloadAction<ViewerMode>) => {
|
isImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
|
||||||
state.viewerMode = action.payload;
|
if (state.isImageViewerOpen && state.imageToCompare) {
|
||||||
|
state.imageToCompare = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.isImageViewerOpen = action.payload;
|
||||||
},
|
},
|
||||||
comparedImagesSwapped: (state) => {
|
comparedImagesSwapped: (state) => {
|
||||||
if (state.imageToCompare) {
|
if (state.imageToCompare) {
|
||||||
@ -138,7 +142,7 @@ export const {
|
|||||||
boardSearchTextChanged,
|
boardSearchTextChanged,
|
||||||
moreImagesLoaded,
|
moreImagesLoaded,
|
||||||
alwaysShowImageSizeBadgeChanged,
|
alwaysShowImageSizeBadgeChanged,
|
||||||
viewerModeChanged,
|
isImageViewerOpenChanged,
|
||||||
imageToCompareChanged,
|
imageToCompareChanged,
|
||||||
comparisonModeChanged,
|
comparisonModeChanged,
|
||||||
comparedImagesSwapped,
|
comparedImagesSwapped,
|
||||||
@ -164,5 +168,13 @@ export const galleryPersistConfig: PersistConfig<GalleryState> = {
|
|||||||
name: gallerySlice.name,
|
name: gallerySlice.name,
|
||||||
initialState: initialGalleryState,
|
initialState: initialGalleryState,
|
||||||
migrate: migrateGalleryState,
|
migrate: migrateGalleryState,
|
||||||
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'viewerMode', 'imageToCompare'],
|
persistDenylist: [
|
||||||
|
'selection',
|
||||||
|
'selectedBoardId',
|
||||||
|
'galleryView',
|
||||||
|
'offset',
|
||||||
|
'limit',
|
||||||
|
'isImageViewerOpen',
|
||||||
|
'imageToCompare',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
@ -8,7 +8,6 @@ export const IMAGE_LIMIT = 20;
|
|||||||
export type GalleryView = 'images' | 'assets';
|
export type GalleryView = 'images' | 'assets';
|
||||||
export type BoardId = 'none' | (string & Record<never, never>);
|
export type BoardId = 'none' | (string & Record<never, never>);
|
||||||
export type ComparisonMode = 'slider' | 'side-by-side';
|
export type ComparisonMode = 'slider' | 'side-by-side';
|
||||||
export type ViewerMode = 'edit' | 'view' | 'compare';
|
|
||||||
|
|
||||||
export type GalleryState = {
|
export type GalleryState = {
|
||||||
selection: ImageDTO[];
|
selection: ImageDTO[];
|
||||||
@ -25,5 +24,5 @@ export type GalleryState = {
|
|||||||
imageToCompare: ImageDTO | null;
|
imageToCompare: ImageDTO | null;
|
||||||
comparisonMode: ComparisonMode;
|
comparisonMode: ComparisonMode;
|
||||||
sliderFit: 'contain' | 'fill';
|
sliderFit: 'contain' | 'fill';
|
||||||
viewerMode: ViewerMode;
|
isImageViewerOpen: boolean;
|
||||||
};
|
};
|
||||||
|
@ -3,7 +3,7 @@ import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/u
|
|||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
|
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
|
||||||
import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent';
|
import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent';
|
||||||
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
|
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
|
||||||
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
|
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
|
||||||
import QueueControls from 'features/queue/components/QueueControls';
|
import QueueControls from 'features/queue/components/QueueControls';
|
||||||
import { SDXLPrompts } from 'features/sdxl/components/SDXLPrompts/SDXLPrompts';
|
import { SDXLPrompts } from 'features/sdxl/components/SDXLPrompts/SDXLPrompts';
|
||||||
@ -51,7 +51,7 @@ const ParametersPanelTextToImage = () => {
|
|||||||
const onChangeTabs = useCallback(
|
const onChangeTabs = useCallback(
|
||||||
(i: number) => {
|
(i: number) => {
|
||||||
if (i === 1) {
|
if (i === 1) {
|
||||||
dispatch(viewerModeChanged('edit'));
|
dispatch(isImageViewerOpenChanged(false));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
|
Loading…
Reference in New Issue
Block a user