mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
fix(ui): fix gallery display bug, major lag
- Fixed a bug where after you load more, changing boards doesn't work. The offset and limit for the list image query had some wonky logic, now resolved. - Addressed major lag in gallery when selecting an image. Both issues were related to the useMultiselect and useGalleryImages hooks, which caused every image in the gallery to re-render on whenever the selection changed. There's no way to memoize away this - we need to know when the selection changes. This is a longstanding issue. The selection is only used in a callback, though - the onClick handler for an image to select it (or add it to the existing selection). We don't really need the reactivity for a callback, so we don't need to listen for changes to the selection. The logic to handle multiple selection is moved to a new `galleryImageClicked` listener, which does all the selection right when it is needed. The result is that gallery images no long need to do heavy re-renders on any selection change. Besides the multiselect click handler, there was also inefficient use of DND payloads. Previously, the `IMAGE_DTOS` type had a payload of image DTO objects. This was only used to drag gallery selection into a board. There is no need to hold onto image DTOs when we have the selection state already in redux. We were recalculating this payload for every image, on every tick. This payload is now just the board id (the only piece of information we need for this particular DND event). - I also removed some unused DND types while making this change.
This commit is contained in:
parent
7d93329401
commit
23c8a893e1
@ -5,6 +5,7 @@ import type {
|
||||
UnknownAction,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
|
||||
import type { AppDispatch, RootState } from 'app/store/store';
|
||||
|
||||
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
|
||||
@ -117,6 +118,9 @@ addImageToDeleteSelectedListener();
|
||||
addImagesStarredListener();
|
||||
addImagesUnstarredListener();
|
||||
|
||||
// Gallery
|
||||
addGalleryImageClickedListener();
|
||||
|
||||
// User Invoked
|
||||
addEnqueueRequestedCanvasListener();
|
||||
addEnqueueRequestedNodes();
|
||||
@ -135,19 +139,7 @@ addCanvasMergedListener();
|
||||
addStagingAreaImageSavedListener();
|
||||
addCommitStagingAreaImageListener();
|
||||
|
||||
/**
|
||||
* Socket.IO Events - these handle SIO events directly and pass on internal application actions.
|
||||
* We don't handle SIO events in slices via `extraReducers` because some of these events shouldn't
|
||||
* actually be handled at all.
|
||||
*
|
||||
* For example, we don't want to respond to progress events for canceled sessions. To avoid
|
||||
* duplicating the logic to determine if an event should be responded to, we handle all of that
|
||||
* "is this session canceled?" logic in these listeners.
|
||||
*
|
||||
* The `socketGeneratorProgress` listener will then only dispatch the `appSocketGeneratorProgress`
|
||||
* action if it should be handled by the rest of the application. It is this `appSocketGeneratorProgress`
|
||||
* action that is handled by reducers in slices.
|
||||
*/
|
||||
// Socket.IO
|
||||
addGeneratorProgressListener();
|
||||
addGraphExecutionStateCompleteListener();
|
||||
addInvocationCompleteListener();
|
||||
|
@ -0,0 +1,80 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import { imagesSelectors } from 'services/api/util';
|
||||
|
||||
import { startAppListening } from '..';
|
||||
|
||||
export const galleryImageClicked = createAction<{
|
||||
imageDTO: ImageDTO;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}>('gallery/imageClicked');
|
||||
|
||||
/**
|
||||
* This listener handles the logic for selecting images in the gallery.
|
||||
*
|
||||
* Previously, this logic was in a `useCallback` with the whole gallery selection as a dependency. Every time
|
||||
* the selection changed, the callback got recreated and all images rerendered. This could easily block for
|
||||
* hundreds of ms, more for lower end devices.
|
||||
*
|
||||
* Moving this logic into a listener means we don't need to recalculate anything dynamically and the gallery
|
||||
* is much more responsive.
|
||||
*/
|
||||
|
||||
export const addGalleryImageClickedListener = () => {
|
||||
startAppListening({
|
||||
actionCreator: galleryImageClicked,
|
||||
effect: async (action, { dispatch, getState }) => {
|
||||
const { imageDTO, shiftKey, ctrlKey, metaKey } = action.payload;
|
||||
const state = getState();
|
||||
const queryArgs = selectListImagesQueryArgs(state);
|
||||
const { data: listImagesData } =
|
||||
imagesApi.endpoints.listImages.select(queryArgs)(state);
|
||||
|
||||
if (!listImagesData) {
|
||||
// Should never happen if we have clicked a gallery image
|
||||
return;
|
||||
}
|
||||
|
||||
const imageDTOs = imagesSelectors.selectAll(listImagesData);
|
||||
const selection = state.gallery.selection;
|
||||
|
||||
if (shiftKey) {
|
||||
const rangeEndImageName = imageDTO.image_name;
|
||||
const lastSelectedImage = selection[selection.length - 1]?.image_name;
|
||||
const lastClickedIndex = imageDTOs.findIndex(
|
||||
(n) => n.image_name === lastSelectedImage
|
||||
);
|
||||
const currentClickedIndex = imageDTOs.findIndex(
|
||||
(n) => n.image_name === rangeEndImageName
|
||||
);
|
||||
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
|
||||
// We have a valid range!
|
||||
const start = Math.min(lastClickedIndex, currentClickedIndex);
|
||||
const end = Math.max(lastClickedIndex, currentClickedIndex);
|
||||
const imagesToSelect = imageDTOs.slice(start, end + 1);
|
||||
dispatch(selectionChanged(selection.concat(imagesToSelect)));
|
||||
}
|
||||
} else if (ctrlKey || metaKey) {
|
||||
if (
|
||||
selection.some((i) => i.image_name === imageDTO.image_name) &&
|
||||
selection.length > 1
|
||||
) {
|
||||
dispatch(
|
||||
selectionChanged(
|
||||
selection.filter((n) => n.image_name !== imageDTO.image_name)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
dispatch(selectionChanged(selection.concat(imageDTO)));
|
||||
}
|
||||
} else {
|
||||
dispatch(selectionChanged([imageDTO]));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
@ -8,7 +8,7 @@ import {
|
||||
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
|
||||
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
|
||||
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
|
||||
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { isImageFieldInputInstance } from 'features/nodes/types/field';
|
||||
@ -49,7 +49,7 @@ export const addRequestedSingleImageDeletionListener = () => {
|
||||
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
|
||||
const { image_name } = imageDTO;
|
||||
|
||||
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
|
||||
const baseQueryArgs = selectListImagesQueryArgs(state);
|
||||
const { data } =
|
||||
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
|
||||
|
||||
@ -180,9 +180,9 @@ export const addRequestedMultipleImageDeletionListener = () => {
|
||||
imagesApi.endpoints.deleteImages.initiate({ imageDTOs })
|
||||
).unwrap();
|
||||
const state = getState();
|
||||
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
|
||||
const queryArgs = selectListImagesQueryArgs(state);
|
||||
const { data } =
|
||||
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
|
||||
imagesApi.endpoints.listImages.select(queryArgs)(state);
|
||||
|
||||
const newSelectedImageDTO = data
|
||||
? imagesSelectors.selectAll(data)[0]
|
||||
|
@ -12,7 +12,6 @@ import type {
|
||||
} from 'features/dnd/types';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import { workflowExposedFieldAdded } from 'features/nodes/store/workflowSlice';
|
||||
import {
|
||||
initialImageChanged,
|
||||
selectOptimalDimension,
|
||||
@ -35,10 +34,10 @@ export const addImageDroppedListener = () => {
|
||||
|
||||
if (activeData.payloadType === 'IMAGE_DTO') {
|
||||
log.debug({ activeData, overData }, 'Image dropped');
|
||||
} else if (activeData.payloadType === 'IMAGE_DTOS') {
|
||||
} else if (activeData.payloadType === 'GALLERY_SELECTION') {
|
||||
log.debug(
|
||||
{ activeData, overData },
|
||||
`Images (${activeData.payload.imageDTOs.length}) dropped`
|
||||
`Images (${getState().gallery.selection.length}) dropped`
|
||||
);
|
||||
} else if (activeData.payloadType === 'NODE_FIELD') {
|
||||
log.debug(
|
||||
@ -49,19 +48,6 @@ export const addImageDroppedListener = () => {
|
||||
log.debug({ activeData, overData }, `Unknown payload dropped`);
|
||||
}
|
||||
|
||||
if (
|
||||
overData.actionType === 'ADD_FIELD_TO_LINEAR' &&
|
||||
activeData.payloadType === 'NODE_FIELD'
|
||||
) {
|
||||
const { nodeId, field } = activeData.payload;
|
||||
dispatch(
|
||||
workflowExposedFieldAdded({
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Image dropped on current image
|
||||
*/
|
||||
@ -207,10 +193,9 @@ export const addImageDroppedListener = () => {
|
||||
*/
|
||||
if (
|
||||
overData.actionType === 'ADD_TO_BOARD' &&
|
||||
activeData.payloadType === 'IMAGE_DTOS' &&
|
||||
activeData.payload.imageDTOs
|
||||
activeData.payloadType === 'GALLERY_SELECTION'
|
||||
) {
|
||||
const { imageDTOs } = activeData.payload;
|
||||
const imageDTOs = getState().gallery.selection;
|
||||
const { boardId } = overData.context;
|
||||
dispatch(
|
||||
imagesApi.endpoints.addImagesToBoard.initiate({
|
||||
@ -226,10 +211,9 @@ export const addImageDroppedListener = () => {
|
||||
*/
|
||||
if (
|
||||
overData.actionType === 'REMOVE_FROM_BOARD' &&
|
||||
activeData.payloadType === 'IMAGE_DTOS' &&
|
||||
activeData.payload.imageDTOs
|
||||
activeData.payloadType === 'GALLERY_SELECTION'
|
||||
) {
|
||||
const { imageDTOs } = activeData.payload;
|
||||
const imageDTOs = getState().gallery.selection;
|
||||
dispatch(
|
||||
imagesApi.endpoints.removeImagesFromBoard.initiate({
|
||||
imageDTOs,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { ChakraProps } from '@chakra-ui/react';
|
||||
import { Box, Flex, Heading, Image } from '@chakra-ui/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { InvText } from 'common/components/InvText/wrapper';
|
||||
import type { TypesafeDraggableData } from 'features/dnd/types';
|
||||
import { memo } from 'react';
|
||||
@ -34,6 +35,7 @@ const multiImageStyles: ChakraProps['sx'] = {
|
||||
|
||||
const DragPreview = (props: OverlayDragImageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectionCount = useAppSelector((s) => s.gallery.selection.length);
|
||||
if (!props.dragData) {
|
||||
return null;
|
||||
}
|
||||
@ -79,10 +81,10 @@ const DragPreview = (props: OverlayDragImageProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (props.dragData.payloadType === 'IMAGE_DTOS') {
|
||||
if (props.dragData.payloadType === 'GALLERY_SELECTION') {
|
||||
return (
|
||||
<Flex sx={multiImageStyles}>
|
||||
<Heading>{props.dragData.payload.imageDTOs.length}</Heading>
|
||||
<Heading>{selectionCount}</Heading>
|
||||
<Heading size="sm">{t('parameters.images')}</Heading>
|
||||
</Flex>
|
||||
);
|
||||
|
@ -10,6 +10,7 @@ import type {
|
||||
useDroppable as useOriginalDroppable,
|
||||
UseDroppableArguments,
|
||||
} from '@dnd-kit/core';
|
||||
import type { BoardId } from 'features/gallery/store/types';
|
||||
import type {
|
||||
FieldInputInstance,
|
||||
FieldInputTemplate,
|
||||
@ -51,15 +52,6 @@ export type NodesImageDropData = BaseDropData & {
|
||||
};
|
||||
};
|
||||
|
||||
export type NodesMultiImageDropData = BaseDropData & {
|
||||
actionType: 'SET_MULTI_NODES_IMAGE';
|
||||
context: { nodeId: string; fieldName: string };
|
||||
};
|
||||
|
||||
export type AddToBatchDropData = BaseDropData & {
|
||||
actionType: 'ADD_TO_BATCH';
|
||||
};
|
||||
|
||||
export type AddToBoardDropData = BaseDropData & {
|
||||
actionType: 'ADD_TO_BOARD';
|
||||
context: { boardId: string };
|
||||
@ -69,21 +61,14 @@ export type RemoveFromBoardDropData = BaseDropData & {
|
||||
actionType: 'REMOVE_FROM_BOARD';
|
||||
};
|
||||
|
||||
export type AddFieldToLinearViewDropData = BaseDropData & {
|
||||
actionType: 'ADD_FIELD_TO_LINEAR';
|
||||
};
|
||||
|
||||
export type TypesafeDroppableData =
|
||||
| CurrentImageDropData
|
||||
| InitialImageDropData
|
||||
| ControlAdapterDropData
|
||||
| CanvasInitialImageDropData
|
||||
| NodesImageDropData
|
||||
| AddToBatchDropData
|
||||
| NodesMultiImageDropData
|
||||
| AddToBoardDropData
|
||||
| RemoveFromBoardDropData
|
||||
| AddFieldToLinearViewDropData;
|
||||
| RemoveFromBoardDropData;
|
||||
|
||||
type BaseDragData = {
|
||||
id: string;
|
||||
@ -103,15 +88,15 @@ export type ImageDraggableData = BaseDragData & {
|
||||
payload: { imageDTO: ImageDTO };
|
||||
};
|
||||
|
||||
export type ImageDTOsDraggableData = BaseDragData & {
|
||||
payloadType: 'IMAGE_DTOS';
|
||||
payload: { imageDTOs: ImageDTO[] };
|
||||
export type GallerySelectionDraggableData = BaseDragData & {
|
||||
payloadType: 'GALLERY_SELECTION';
|
||||
payload: { boardId: BoardId };
|
||||
};
|
||||
|
||||
export type TypesafeDraggableData =
|
||||
| NodeFieldDraggableData
|
||||
| ImageDraggableData
|
||||
| ImageDTOsDraggableData;
|
||||
| GallerySelectionDraggableData;
|
||||
|
||||
export interface UseDroppableTypesafeArguments
|
||||
extends Omit<UseDroppableArguments, 'data'> {
|
||||
|
@ -16,8 +16,6 @@ export const isValidDrop = (
|
||||
}
|
||||
|
||||
switch (actionType) {
|
||||
case 'ADD_FIELD_TO_LINEAR':
|
||||
return payloadType === 'NODE_FIELD';
|
||||
case 'SET_CURRENT_IMAGE':
|
||||
return payloadType === 'IMAGE_DTO';
|
||||
case 'SET_INITIAL_IMAGE':
|
||||
@ -28,15 +26,13 @@ export const isValidDrop = (
|
||||
return payloadType === 'IMAGE_DTO';
|
||||
case 'SET_NODES_IMAGE':
|
||||
return payloadType === 'IMAGE_DTO';
|
||||
case 'SET_MULTI_NODES_IMAGE':
|
||||
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
|
||||
case 'ADD_TO_BATCH':
|
||||
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
|
||||
case 'ADD_TO_BOARD': {
|
||||
// If the board is the same, don't allow the drop
|
||||
|
||||
// Check the payload types
|
||||
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
|
||||
const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
|
||||
payloadType
|
||||
);
|
||||
if (!isPayloadValid) {
|
||||
return false;
|
||||
}
|
||||
@ -50,12 +46,10 @@ export const isValidDrop = (
|
||||
return currentBoard !== destinationBoard;
|
||||
}
|
||||
|
||||
if (payloadType === 'IMAGE_DTOS') {
|
||||
if (payloadType === 'GALLERY_SELECTION') {
|
||||
// Assume all images are on the same board - this is true for the moment
|
||||
const { imageDTOs } = active.data.current.payload;
|
||||
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
|
||||
const currentBoard = active.data.current.payload.boardId;
|
||||
const destinationBoard = overData.context.boardId;
|
||||
|
||||
return currentBoard !== destinationBoard;
|
||||
}
|
||||
|
||||
@ -65,7 +59,9 @@ export const isValidDrop = (
|
||||
// If the board is the same, don't allow the drop
|
||||
|
||||
// Check the payload types
|
||||
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
|
||||
const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
|
||||
payloadType
|
||||
);
|
||||
if (!isPayloadValid) {
|
||||
return false;
|
||||
}
|
||||
@ -78,11 +74,8 @@ export const isValidDrop = (
|
||||
return currentBoard !== 'none';
|
||||
}
|
||||
|
||||
if (payloadType === 'IMAGE_DTOS') {
|
||||
// Assume all images are on the same board - this is true for the moment
|
||||
const { imageDTOs } = active.data.current.payload;
|
||||
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
|
||||
|
||||
if (payloadType === 'GALLERY_SELECTION') {
|
||||
const currentBoard = active.data.current.payload.boardId;
|
||||
return currentBoard !== 'none';
|
||||
}
|
||||
|
||||
|
@ -2,15 +2,15 @@ import type { SystemStyleObject } from '@chakra-ui/react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $customStarUI } from 'app/store/nanostores/customStarUI';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
|
||||
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
|
||||
import { $shift } from 'common/hooks/useGlobalModifiers';
|
||||
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
|
||||
import type {
|
||||
GallerySelectionDraggableData,
|
||||
ImageDraggableData,
|
||||
ImageDTOsDraggableData,
|
||||
TypesafeDraggableData,
|
||||
} from 'features/dnd/types';
|
||||
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
||||
@ -42,8 +42,8 @@ const GalleryImage = (props: HoverableImageProps) => {
|
||||
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
|
||||
const shift = useStore($shift);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { handleClick, isSelected, selection, selectionCount } =
|
||||
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
|
||||
const { handleClick, isSelected, areMultiplesSelected } =
|
||||
useMultiselect(imageDTO);
|
||||
|
||||
const customStarUi = useStore($customStarUI);
|
||||
@ -51,7 +51,7 @@ const GalleryImage = (props: HoverableImageProps) => {
|
||||
const imageContainerRef = useScrollIntoView(
|
||||
isSelected,
|
||||
props.index,
|
||||
selectionCount
|
||||
areMultiplesSelected
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@ -66,11 +66,11 @@ const GalleryImage = (props: HoverableImageProps) => {
|
||||
);
|
||||
|
||||
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
|
||||
if (selectionCount > 1) {
|
||||
const data: ImageDTOsDraggableData = {
|
||||
if (areMultiplesSelected) {
|
||||
const data: GallerySelectionDraggableData = {
|
||||
id: 'gallery-image',
|
||||
payloadType: 'IMAGE_DTOS',
|
||||
payload: { imageDTOs: selection },
|
||||
payloadType: 'GALLERY_SELECTION',
|
||||
payload: { boardId: selectedBoardId },
|
||||
};
|
||||
return data;
|
||||
}
|
||||
@ -83,7 +83,7 @@ const GalleryImage = (props: HoverableImageProps) => {
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}, [imageDTO, selection, selectionCount]);
|
||||
}, [imageDTO, selectedBoardId, areMultiplesSelected]);
|
||||
|
||||
const [starImages] = useStarImagesMutation();
|
||||
const [unstarImages] = useUnstarImagesMutation();
|
||||
|
@ -1,46 +1,28 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { IMAGE_LIMIT } from 'features/gallery/store/types';
|
||||
import { atom } from 'nanostores';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { moreImagesLoaded } from 'features/gallery/store/gallerySlice';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
useGetBoardAssetsTotalQuery,
|
||||
useGetBoardImagesTotalQuery,
|
||||
} from 'services/api/endpoints/boards';
|
||||
import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
import type { ListImagesArgs } from 'services/api/types';
|
||||
|
||||
// The gallery is a singleton but multiple components need access to its query data.
|
||||
// If we don't define the query args outside of the hook, then each component will
|
||||
// have its own query args and trigger multiple requests. We use an atom to store
|
||||
// the query args outside of the hook so that all consumers use the same query args.
|
||||
const $queryArgs = atom<ListImagesArgs | null>(null);
|
||||
|
||||
/**
|
||||
* Provides access to the gallery images and a way to imperatively fetch more.
|
||||
*
|
||||
* This hook is a singleton.
|
||||
*/
|
||||
export const useGalleryImages = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const galleryView = useAppSelector((s) => s.gallery.galleryView);
|
||||
const baseQueryArgs = useAppSelector(selectListImagesBaseQueryArgs);
|
||||
const queryArgs = useStore($queryArgs);
|
||||
const queryResult = useListImagesQuery(queryArgs ?? baseQueryArgs);
|
||||
const boardId = useMemo(
|
||||
() => baseQueryArgs.board_id ?? 'none',
|
||||
[baseQueryArgs.board_id]
|
||||
);
|
||||
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(boardId);
|
||||
const { data: imagesTotal } = useGetBoardImagesTotalQuery(boardId);
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const queryResult = useListImagesQuery(queryArgs);
|
||||
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
|
||||
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(selectedBoardId);
|
||||
const { data: imagesTotal } = useGetBoardImagesTotalQuery(selectedBoardId);
|
||||
const currentViewTotal = useMemo(
|
||||
() => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total),
|
||||
[assetsTotal?.total, galleryView, imagesTotal?.total]
|
||||
);
|
||||
const loadedImagesCount = useMemo(
|
||||
() => queryResult.data?.ids.length ?? 0,
|
||||
[queryResult.data?.ids.length]
|
||||
);
|
||||
const areMoreImagesAvailable = useMemo(() => {
|
||||
if (!currentViewTotal || !queryResult.data) {
|
||||
return false;
|
||||
@ -48,16 +30,8 @@ export const useGalleryImages = () => {
|
||||
return queryResult.data.ids.length < currentViewTotal;
|
||||
}, [queryResult.data, currentViewTotal]);
|
||||
const handleLoadMoreImages = useCallback(() => {
|
||||
// To load more images, we update the query args with an offset and limit.
|
||||
const _queryArgs: ListImagesArgs = loadedImagesCount
|
||||
? {
|
||||
...baseQueryArgs,
|
||||
offset: loadedImagesCount,
|
||||
limit: IMAGE_LIMIT,
|
||||
}
|
||||
: baseQueryArgs;
|
||||
$queryArgs.set(_queryArgs);
|
||||
}, [baseQueryArgs, loadedImagesCount]);
|
||||
dispatch(moreImagesLoaded());
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
areMoreImagesAvailable,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
|
||||
import {
|
||||
selectGallerySlice,
|
||||
selectionChanged,
|
||||
@ -9,24 +9,20 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
import { imagesSelectors } from 'services/api/util';
|
||||
|
||||
const selectGallerySelection = createMemoizedSelector(
|
||||
selectGallerySlice,
|
||||
(gallery) => gallery.selection
|
||||
);
|
||||
|
||||
const EMPTY_ARRAY: ImageDTO[] = [];
|
||||
|
||||
export const useMultiselect = (imageDTO?: ImageDTO) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const selection = useAppSelector(selectGallerySelection);
|
||||
const { data } = useGalleryImages().queryResult;
|
||||
const imageDTOs = useMemo(
|
||||
() => (data ? imagesSelectors.selectAll(data) : EMPTY_ARRAY),
|
||||
[data]
|
||||
const areMultiplesSelected = useAppSelector(
|
||||
(s) => s.gallery.selection.length > 1
|
||||
);
|
||||
|
||||
const selectIsSelected = useMemo(
|
||||
() =>
|
||||
createSelector(selectGallerySlice, (gallery) =>
|
||||
gallery.selection.some((i) => i.image_name === imageDTO?.image_name)
|
||||
),
|
||||
[imageDTO?.image_name]
|
||||
);
|
||||
const isSelected = useAppSelector(selectIsSelected);
|
||||
const isMultiSelectEnabled = useFeatureStatus('multiselect').isFeatureEnabled;
|
||||
|
||||
const handleClick = useCallback(
|
||||
@ -39,55 +35,20 @@ export const useMultiselect = (imageDTO?: ImageDTO) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
const rangeEndImageName = imageDTO.image_name;
|
||||
const lastSelectedImage = selection[selection.length - 1]?.image_name;
|
||||
const lastClickedIndex = imageDTOs.findIndex(
|
||||
(n) => n.image_name === lastSelectedImage
|
||||
);
|
||||
const currentClickedIndex = imageDTOs.findIndex(
|
||||
(n) => n.image_name === rangeEndImageName
|
||||
);
|
||||
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
|
||||
// We have a valid range!
|
||||
const start = Math.min(lastClickedIndex, currentClickedIndex);
|
||||
const end = Math.max(lastClickedIndex, currentClickedIndex);
|
||||
const imagesToSelect = imageDTOs.slice(start, end + 1);
|
||||
dispatch(selectionChanged(selection.concat(imagesToSelect)));
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (
|
||||
selection.some((i) => i.image_name === imageDTO.image_name) &&
|
||||
selection.length > 1
|
||||
) {
|
||||
dispatch(
|
||||
selectionChanged(
|
||||
selection.filter((n) => n.image_name !== imageDTO.image_name)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
dispatch(selectionChanged(selection.concat(imageDTO)));
|
||||
}
|
||||
} else {
|
||||
dispatch(selectionChanged([imageDTO]));
|
||||
}
|
||||
dispatch(
|
||||
galleryImageClicked({
|
||||
imageDTO,
|
||||
shiftKey: e.shiftKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
metaKey: e.metaKey,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, imageDTO, imageDTOs, selection, isMultiSelectEnabled]
|
||||
[dispatch, imageDTO, isMultiSelectEnabled]
|
||||
);
|
||||
|
||||
const isSelected = useMemo(
|
||||
() =>
|
||||
imageDTO
|
||||
? selection.some((i) => i.image_name === imageDTO.image_name)
|
||||
: false,
|
||||
[imageDTO, selection]
|
||||
);
|
||||
|
||||
const selectionCount = useMemo(() => selection.length, [selection.length]);
|
||||
|
||||
return {
|
||||
selection,
|
||||
selectionCount,
|
||||
areMultiplesSelected,
|
||||
isSelected,
|
||||
handleClick,
|
||||
};
|
||||
|
@ -19,12 +19,12 @@ import { useEffect, useRef } from 'react';
|
||||
export const useScrollIntoView = (
|
||||
isSelected: boolean,
|
||||
index: number,
|
||||
selectionCount: number
|
||||
areMultiplesSelected: boolean
|
||||
) => {
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelected || selectionCount !== 1) {
|
||||
if (!isSelected || areMultiplesSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ export const useScrollIntoView = (
|
||||
align: getScrollToIndexAlign(index, range),
|
||||
});
|
||||
}
|
||||
}, [isSelected, index, selectionCount]);
|
||||
}, [isSelected, index, areMultiplesSelected]);
|
||||
|
||||
return imageContainerRef;
|
||||
};
|
||||
|
@ -7,4 +7,6 @@ export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
|
||||
'selection',
|
||||
'selectedBoardId',
|
||||
'galleryView',
|
||||
'offset',
|
||||
'limit',
|
||||
];
|
||||
|
@ -1,33 +1,24 @@
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
|
||||
import type { ListImagesArgs } from 'services/api/types';
|
||||
|
||||
import {
|
||||
ASSETS_CATEGORIES,
|
||||
IMAGE_CATEGORIES,
|
||||
INITIAL_IMAGE_LIMIT,
|
||||
} from './types';
|
||||
} from 'features/gallery/store/types';
|
||||
import type { ListImagesArgs } from 'services/api/types';
|
||||
|
||||
export const selectLastSelectedImage = createMemoizedSelector(
|
||||
selectGallerySlice,
|
||||
(gallery) => gallery.selection[gallery.selection.length - 1]
|
||||
);
|
||||
|
||||
export const selectListImagesBaseQueryArgs = createMemoizedSelector(
|
||||
export const selectListImagesQueryArgs = createMemoizedSelector(
|
||||
selectGallerySlice,
|
||||
(gallery) => {
|
||||
const { selectedBoardId, galleryView } = gallery;
|
||||
const categories =
|
||||
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
|
||||
|
||||
const listImagesBaseQueryArgs: ListImagesArgs = {
|
||||
board_id: selectedBoardId,
|
||||
categories,
|
||||
offset: 0,
|
||||
limit: INITIAL_IMAGE_LIMIT,
|
||||
is_intermediate: false,
|
||||
};
|
||||
|
||||
return listImagesBaseQueryArgs;
|
||||
}
|
||||
(gallery): ListImagesArgs => ({
|
||||
board_id: gallery.selectedBoardId,
|
||||
categories:
|
||||
gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
|
||||
offset: gallery.offset,
|
||||
limit: gallery.limit,
|
||||
is_intermediate: false,
|
||||
})
|
||||
);
|
||||
|
@ -7,6 +7,7 @@ import { imagesApi } from 'services/api/endpoints/images';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
import type { BoardId, GalleryState, GalleryView } from './types';
|
||||
import { IMAGE_LIMIT, INITIAL_IMAGE_LIMIT } from './types';
|
||||
|
||||
export const initialGalleryState: GalleryState = {
|
||||
selection: [],
|
||||
@ -17,6 +18,8 @@ export const initialGalleryState: GalleryState = {
|
||||
selectedBoardId: 'none',
|
||||
galleryView: 'images',
|
||||
boardSearchText: '',
|
||||
limit: INITIAL_IMAGE_LIMIT,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
export const gallerySlice = createSlice({
|
||||
@ -44,6 +47,8 @@ export const gallerySlice = createSlice({
|
||||
) => {
|
||||
state.selectedBoardId = action.payload.boardId;
|
||||
state.galleryView = 'images';
|
||||
state.offset = 0;
|
||||
state.limit = INITIAL_IMAGE_LIMIT;
|
||||
},
|
||||
autoAddBoardIdChanged: (state, action: PayloadAction<BoardId>) => {
|
||||
if (!action.payload) {
|
||||
@ -54,10 +59,21 @@ export const gallerySlice = createSlice({
|
||||
},
|
||||
galleryViewChanged: (state, action: PayloadAction<GalleryView>) => {
|
||||
state.galleryView = action.payload;
|
||||
state.offset = 0;
|
||||
state.limit = INITIAL_IMAGE_LIMIT;
|
||||
},
|
||||
boardSearchTextChanged: (state, action: PayloadAction<string>) => {
|
||||
state.boardSearchText = action.payload;
|
||||
},
|
||||
moreImagesLoaded: (state) => {
|
||||
if (state.offset === 0 && state.limit === INITIAL_IMAGE_LIMIT) {
|
||||
state.offset = INITIAL_IMAGE_LIMIT;
|
||||
state.limit = IMAGE_LIMIT;
|
||||
} else {
|
||||
state.offset += IMAGE_LIMIT;
|
||||
state.limit += IMAGE_LIMIT;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addMatcher(isAnyBoardDeleted, (state, action) => {
|
||||
@ -96,6 +112,7 @@ export const {
|
||||
galleryViewChanged,
|
||||
selectionChanged,
|
||||
boardSearchTextChanged,
|
||||
moreImagesLoaded,
|
||||
} = gallerySlice.actions;
|
||||
|
||||
export default gallerySlice.reducer;
|
||||
|
@ -22,4 +22,6 @@ export type GalleryState = {
|
||||
selectedBoardId: BoardId;
|
||||
galleryView: GalleryView;
|
||||
boardSearchText: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user