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:
psychedelicious 2024-01-11 00:07:09 +11:00 committed by Kent Keirsey
parent 7d93329401
commit 23c8a893e1
15 changed files with 193 additions and 210 deletions

View File

@ -5,6 +5,7 @@ import type {
UnknownAction, UnknownAction,
} from '@reduxjs/toolkit'; } from '@reduxjs/toolkit';
import { addListener, createListenerMiddleware } 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 type { AppDispatch, RootState } from 'app/store/store';
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener'; import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
@ -117,6 +118,9 @@ addImageToDeleteSelectedListener();
addImagesStarredListener(); addImagesStarredListener();
addImagesUnstarredListener(); addImagesUnstarredListener();
// Gallery
addGalleryImageClickedListener();
// User Invoked // User Invoked
addEnqueueRequestedCanvasListener(); addEnqueueRequestedCanvasListener();
addEnqueueRequestedNodes(); addEnqueueRequestedNodes();
@ -135,19 +139,7 @@ addCanvasMergedListener();
addStagingAreaImageSavedListener(); addStagingAreaImageSavedListener();
addCommitStagingAreaImageListener(); addCommitStagingAreaImageListener();
/** // Socket.IO
* 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.
*/
addGeneratorProgressListener(); addGeneratorProgressListener();
addGraphExecutionStateCompleteListener(); addGraphExecutionStateCompleteListener();
addInvocationCompleteListener(); addInvocationCompleteListener();

View File

@ -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]));
}
},
});
};

View File

@ -8,7 +8,7 @@ import {
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types'; import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice'; 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 { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field'; import { isImageFieldInputInstance } from 'features/nodes/types/field';
@ -49,7 +49,7 @@ export const addRequestedSingleImageDeletionListener = () => {
if (imageDTO && imageDTO?.image_name === lastSelectedImage) { if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
const { image_name } = imageDTO; const { image_name } = imageDTO;
const baseQueryArgs = selectListImagesBaseQueryArgs(state); const baseQueryArgs = selectListImagesQueryArgs(state);
const { data } = const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state); imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
@ -180,9 +180,9 @@ export const addRequestedMultipleImageDeletionListener = () => {
imagesApi.endpoints.deleteImages.initiate({ imageDTOs }) imagesApi.endpoints.deleteImages.initiate({ imageDTOs })
).unwrap(); ).unwrap();
const state = getState(); const state = getState();
const baseQueryArgs = selectListImagesBaseQueryArgs(state); const queryArgs = selectListImagesQueryArgs(state);
const { data } = const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state); imagesApi.endpoints.listImages.select(queryArgs)(state);
const newSelectedImageDTO = data const newSelectedImageDTO = data
? imagesSelectors.selectAll(data)[0] ? imagesSelectors.selectAll(data)[0]

View File

@ -12,7 +12,6 @@ import type {
} from 'features/dnd/types'; } from 'features/dnd/types';
import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { workflowExposedFieldAdded } from 'features/nodes/store/workflowSlice';
import { import {
initialImageChanged, initialImageChanged,
selectOptimalDimension, selectOptimalDimension,
@ -35,10 +34,10 @@ export const addImageDroppedListener = () => {
if (activeData.payloadType === 'IMAGE_DTO') { if (activeData.payloadType === 'IMAGE_DTO') {
log.debug({ activeData, overData }, 'Image dropped'); log.debug({ activeData, overData }, 'Image dropped');
} else if (activeData.payloadType === 'IMAGE_DTOS') { } else if (activeData.payloadType === 'GALLERY_SELECTION') {
log.debug( log.debug(
{ activeData, overData }, { activeData, overData },
`Images (${activeData.payload.imageDTOs.length}) dropped` `Images (${getState().gallery.selection.length}) dropped`
); );
} else if (activeData.payloadType === 'NODE_FIELD') { } else if (activeData.payloadType === 'NODE_FIELD') {
log.debug( log.debug(
@ -49,19 +48,6 @@ export const addImageDroppedListener = () => {
log.debug({ activeData, overData }, `Unknown payload dropped`); 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 * Image dropped on current image
*/ */
@ -207,10 +193,9 @@ export const addImageDroppedListener = () => {
*/ */
if ( if (
overData.actionType === 'ADD_TO_BOARD' && overData.actionType === 'ADD_TO_BOARD' &&
activeData.payloadType === 'IMAGE_DTOS' && activeData.payloadType === 'GALLERY_SELECTION'
activeData.payload.imageDTOs
) { ) {
const { imageDTOs } = activeData.payload; const imageDTOs = getState().gallery.selection;
const { boardId } = overData.context; const { boardId } = overData.context;
dispatch( dispatch(
imagesApi.endpoints.addImagesToBoard.initiate({ imagesApi.endpoints.addImagesToBoard.initiate({
@ -226,10 +211,9 @@ export const addImageDroppedListener = () => {
*/ */
if ( if (
overData.actionType === 'REMOVE_FROM_BOARD' && overData.actionType === 'REMOVE_FROM_BOARD' &&
activeData.payloadType === 'IMAGE_DTOS' && activeData.payloadType === 'GALLERY_SELECTION'
activeData.payload.imageDTOs
) { ) {
const { imageDTOs } = activeData.payload; const imageDTOs = getState().gallery.selection;
dispatch( dispatch(
imagesApi.endpoints.removeImagesFromBoard.initiate({ imagesApi.endpoints.removeImagesFromBoard.initiate({
imageDTOs, imageDTOs,

View File

@ -1,5 +1,6 @@
import type { ChakraProps } from '@chakra-ui/react'; import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, Heading, Image } 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 { InvText } from 'common/components/InvText/wrapper';
import type { TypesafeDraggableData } from 'features/dnd/types'; import type { TypesafeDraggableData } from 'features/dnd/types';
import { memo } from 'react'; import { memo } from 'react';
@ -34,6 +35,7 @@ const multiImageStyles: ChakraProps['sx'] = {
const DragPreview = (props: OverlayDragImageProps) => { const DragPreview = (props: OverlayDragImageProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const selectionCount = useAppSelector((s) => s.gallery.selection.length);
if (!props.dragData) { if (!props.dragData) {
return null; return null;
} }
@ -79,10 +81,10 @@ const DragPreview = (props: OverlayDragImageProps) => {
); );
} }
if (props.dragData.payloadType === 'IMAGE_DTOS') { if (props.dragData.payloadType === 'GALLERY_SELECTION') {
return ( return (
<Flex sx={multiImageStyles}> <Flex sx={multiImageStyles}>
<Heading>{props.dragData.payload.imageDTOs.length}</Heading> <Heading>{selectionCount}</Heading>
<Heading size="sm">{t('parameters.images')}</Heading> <Heading size="sm">{t('parameters.images')}</Heading>
</Flex> </Flex>
); );

View File

@ -10,6 +10,7 @@ import type {
useDroppable as useOriginalDroppable, useDroppable as useOriginalDroppable,
UseDroppableArguments, UseDroppableArguments,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import type { BoardId } from 'features/gallery/store/types';
import type { import type {
FieldInputInstance, FieldInputInstance,
FieldInputTemplate, 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 & { export type AddToBoardDropData = BaseDropData & {
actionType: 'ADD_TO_BOARD'; actionType: 'ADD_TO_BOARD';
context: { boardId: string }; context: { boardId: string };
@ -69,21 +61,14 @@ export type RemoveFromBoardDropData = BaseDropData & {
actionType: 'REMOVE_FROM_BOARD'; actionType: 'REMOVE_FROM_BOARD';
}; };
export type AddFieldToLinearViewDropData = BaseDropData & {
actionType: 'ADD_FIELD_TO_LINEAR';
};
export type TypesafeDroppableData = export type TypesafeDroppableData =
| CurrentImageDropData | CurrentImageDropData
| InitialImageDropData | InitialImageDropData
| ControlAdapterDropData | ControlAdapterDropData
| CanvasInitialImageDropData | CanvasInitialImageDropData
| NodesImageDropData | NodesImageDropData
| AddToBatchDropData
| NodesMultiImageDropData
| AddToBoardDropData | AddToBoardDropData
| RemoveFromBoardDropData | RemoveFromBoardDropData;
| AddFieldToLinearViewDropData;
type BaseDragData = { type BaseDragData = {
id: string; id: string;
@ -103,15 +88,15 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO }; payload: { imageDTO: ImageDTO };
}; };
export type ImageDTOsDraggableData = BaseDragData & { export type GallerySelectionDraggableData = BaseDragData & {
payloadType: 'IMAGE_DTOS'; payloadType: 'GALLERY_SELECTION';
payload: { imageDTOs: ImageDTO[] }; payload: { boardId: BoardId };
}; };
export type TypesafeDraggableData = export type TypesafeDraggableData =
| NodeFieldDraggableData | NodeFieldDraggableData
| ImageDraggableData | ImageDraggableData
| ImageDTOsDraggableData; | GallerySelectionDraggableData;
export interface UseDroppableTypesafeArguments export interface UseDroppableTypesafeArguments
extends Omit<UseDroppableArguments, 'data'> { extends Omit<UseDroppableArguments, 'data'> {

View File

@ -16,8 +16,6 @@ export const isValidDrop = (
} }
switch (actionType) { switch (actionType) {
case 'ADD_FIELD_TO_LINEAR':
return payloadType === 'NODE_FIELD';
case 'SET_CURRENT_IMAGE': case 'SET_CURRENT_IMAGE':
return payloadType === 'IMAGE_DTO'; return payloadType === 'IMAGE_DTO';
case 'SET_INITIAL_IMAGE': case 'SET_INITIAL_IMAGE':
@ -28,15 +26,13 @@ export const isValidDrop = (
return payloadType === 'IMAGE_DTO'; return payloadType === 'IMAGE_DTO';
case 'SET_NODES_IMAGE': case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO'; 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': { case 'ADD_TO_BOARD': {
// If the board is the same, don't allow the drop // If the board is the same, don't allow the drop
// Check the payload types // Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS'; const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
payloadType
);
if (!isPayloadValid) { if (!isPayloadValid) {
return false; return false;
} }
@ -50,12 +46,10 @@ export const isValidDrop = (
return currentBoard !== destinationBoard; 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 // Assume all images are on the same board - this is true for the moment
const { imageDTOs } = active.data.current.payload; const currentBoard = active.data.current.payload.boardId;
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
const destinationBoard = overData.context.boardId; const destinationBoard = overData.context.boardId;
return currentBoard !== destinationBoard; return currentBoard !== destinationBoard;
} }
@ -65,7 +59,9 @@ export const isValidDrop = (
// If the board is the same, don't allow the drop // If the board is the same, don't allow the drop
// Check the payload types // Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS'; const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
payloadType
);
if (!isPayloadValid) { if (!isPayloadValid) {
return false; return false;
} }
@ -78,11 +74,8 @@ export const isValidDrop = (
return currentBoard !== 'none'; return currentBoard !== 'none';
} }
if (payloadType === 'IMAGE_DTOS') { if (payloadType === 'GALLERY_SELECTION') {
// Assume all images are on the same board - this is true for the moment const currentBoard = active.data.current.payload.boardId;
const { imageDTOs } = active.data.current.payload;
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
return currentBoard !== 'none'; return currentBoard !== 'none';
} }

View File

@ -2,15 +2,15 @@ import type { SystemStyleObject } from '@chakra-ui/react';
import { Box, Flex } from '@chakra-ui/react'; import { Box, Flex } from '@chakra-ui/react';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI'; 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 IAIDndImage from 'common/components/IAIDndImage';
import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton'; import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import { $shift } from 'common/hooks/useGlobalModifiers'; import { $shift } from 'common/hooks/useGlobalModifiers';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import type { import type {
GallerySelectionDraggableData,
ImageDraggableData, ImageDraggableData,
ImageDTOsDraggableData,
TypesafeDraggableData, TypesafeDraggableData,
} from 'features/dnd/types'; } from 'features/dnd/types';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
@ -42,8 +42,8 @@ const GalleryImage = (props: HoverableImageProps) => {
const { currentData: imageDTO } = useGetImageDTOQuery(imageName); const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const shift = useStore($shift); const shift = useStore($shift);
const { t } = useTranslation(); const { t } = useTranslation();
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const { handleClick, isSelected, selection, selectionCount } = const { handleClick, isSelected, areMultiplesSelected } =
useMultiselect(imageDTO); useMultiselect(imageDTO);
const customStarUi = useStore($customStarUI); const customStarUi = useStore($customStarUI);
@ -51,7 +51,7 @@ const GalleryImage = (props: HoverableImageProps) => {
const imageContainerRef = useScrollIntoView( const imageContainerRef = useScrollIntoView(
isSelected, isSelected,
props.index, props.index,
selectionCount areMultiplesSelected
); );
const handleDelete = useCallback( const handleDelete = useCallback(
@ -66,11 +66,11 @@ const GalleryImage = (props: HoverableImageProps) => {
); );
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => { const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (selectionCount > 1) { if (areMultiplesSelected) {
const data: ImageDTOsDraggableData = { const data: GallerySelectionDraggableData = {
id: 'gallery-image', id: 'gallery-image',
payloadType: 'IMAGE_DTOS', payloadType: 'GALLERY_SELECTION',
payload: { imageDTOs: selection }, payload: { boardId: selectedBoardId },
}; };
return data; return data;
} }
@ -83,7 +83,7 @@ const GalleryImage = (props: HoverableImageProps) => {
}; };
return data; return data;
} }
}, [imageDTO, selection, selectionCount]); }, [imageDTO, selectedBoardId, areMultiplesSelected]);
const [starImages] = useStarImagesMutation(); const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation(); const [unstarImages] = useUnstarImagesMutation();

View File

@ -1,46 +1,28 @@
import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector } from 'app/store/storeHooks'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors'; import { moreImagesLoaded } from 'features/gallery/store/gallerySlice';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { atom } from 'nanostores';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { import {
useGetBoardAssetsTotalQuery, useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery, useGetBoardImagesTotalQuery,
} from 'services/api/endpoints/boards'; } from 'services/api/endpoints/boards';
import { useListImagesQuery } from 'services/api/endpoints/images'; 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. * Provides access to the gallery images and a way to imperatively fetch more.
*
* This hook is a singleton.
*/ */
export const useGalleryImages = () => { export const useGalleryImages = () => {
const dispatch = useAppDispatch();
const galleryView = useAppSelector((s) => s.gallery.galleryView); const galleryView = useAppSelector((s) => s.gallery.galleryView);
const baseQueryArgs = useAppSelector(selectListImagesBaseQueryArgs); const queryArgs = useAppSelector(selectListImagesQueryArgs);
const queryArgs = useStore($queryArgs); const queryResult = useListImagesQuery(queryArgs);
const queryResult = useListImagesQuery(queryArgs ?? baseQueryArgs); const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const boardId = useMemo( const { data: assetsTotal } = useGetBoardAssetsTotalQuery(selectedBoardId);
() => baseQueryArgs.board_id ?? 'none', const { data: imagesTotal } = useGetBoardImagesTotalQuery(selectedBoardId);
[baseQueryArgs.board_id]
);
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(boardId);
const { data: imagesTotal } = useGetBoardImagesTotalQuery(boardId);
const currentViewTotal = useMemo( const currentViewTotal = useMemo(
() => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total), () => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total),
[assetsTotal?.total, galleryView, imagesTotal?.total] [assetsTotal?.total, galleryView, imagesTotal?.total]
); );
const loadedImagesCount = useMemo(
() => queryResult.data?.ids.length ?? 0,
[queryResult.data?.ids.length]
);
const areMoreImagesAvailable = useMemo(() => { const areMoreImagesAvailable = useMemo(() => {
if (!currentViewTotal || !queryResult.data) { if (!currentViewTotal || !queryResult.data) {
return false; return false;
@ -48,16 +30,8 @@ export const useGalleryImages = () => {
return queryResult.data.ids.length < currentViewTotal; return queryResult.data.ids.length < currentViewTotal;
}, [queryResult.data, currentViewTotal]); }, [queryResult.data, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => { const handleLoadMoreImages = useCallback(() => {
// To load more images, we update the query args with an offset and limit. dispatch(moreImagesLoaded());
const _queryArgs: ListImagesArgs = loadedImagesCount }, [dispatch]);
? {
...baseQueryArgs,
offset: loadedImagesCount,
limit: IMAGE_LIMIT,
}
: baseQueryArgs;
$queryArgs.set(_queryArgs);
}, [baseQueryArgs, loadedImagesCount]);
return { return {
areMoreImagesAvailable, areMoreImagesAvailable,

View File

@ -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 { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { import {
selectGallerySlice, selectGallerySlice,
selectionChanged, selectionChanged,
@ -9,24 +9,20 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types'; 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) => { export const useMultiselect = (imageDTO?: ImageDTO) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const selection = useAppSelector(selectGallerySelection); const areMultiplesSelected = useAppSelector(
const { data } = useGalleryImages().queryResult; (s) => s.gallery.selection.length > 1
const imageDTOs = useMemo(
() => (data ? imagesSelectors.selectAll(data) : EMPTY_ARRAY),
[data]
); );
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 isMultiSelectEnabled = useFeatureStatus('multiselect').isFeatureEnabled;
const handleClick = useCallback( const handleClick = useCallback(
@ -39,55 +35,20 @@ export const useMultiselect = (imageDTO?: ImageDTO) => {
return; return;
} }
if (e.shiftKey) { dispatch(
const rangeEndImageName = imageDTO.image_name; galleryImageClicked({
const lastSelectedImage = selection[selection.length - 1]?.image_name; imageDTO,
const lastClickedIndex = imageDTOs.findIndex( shiftKey: e.shiftKey,
(n) => n.image_name === lastSelectedImage ctrlKey: e.ctrlKey,
); metaKey: e.metaKey,
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, 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 { return {
selection, areMultiplesSelected,
selectionCount,
isSelected, isSelected,
handleClick, handleClick,
}; };

View File

@ -19,12 +19,12 @@ import { useEffect, useRef } from 'react';
export const useScrollIntoView = ( export const useScrollIntoView = (
isSelected: boolean, isSelected: boolean,
index: number, index: number,
selectionCount: number areMultiplesSelected: boolean
) => { ) => {
const imageContainerRef = useRef<HTMLDivElement>(null); const imageContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!isSelected || selectionCount !== 1) { if (!isSelected || areMultiplesSelected) {
return; return;
} }
@ -46,7 +46,7 @@ export const useScrollIntoView = (
align: getScrollToIndexAlign(index, range), align: getScrollToIndexAlign(index, range),
}); });
} }
}, [isSelected, index, selectionCount]); }, [isSelected, index, areMultiplesSelected]);
return imageContainerRef; return imageContainerRef;
}; };

View File

@ -7,4 +7,6 @@ export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
'selection', 'selection',
'selectedBoardId', 'selectedBoardId',
'galleryView', 'galleryView',
'offset',
'limit',
]; ];

View File

@ -1,33 +1,24 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
import type { ListImagesArgs } from 'services/api/types';
import { import {
ASSETS_CATEGORIES, ASSETS_CATEGORIES,
IMAGE_CATEGORIES, IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT, } from 'features/gallery/store/types';
} from './types'; import type { ListImagesArgs } from 'services/api/types';
export const selectLastSelectedImage = createMemoizedSelector( export const selectLastSelectedImage = createMemoizedSelector(
selectGallerySlice, selectGallerySlice,
(gallery) => gallery.selection[gallery.selection.length - 1] (gallery) => gallery.selection[gallery.selection.length - 1]
); );
export const selectListImagesBaseQueryArgs = createMemoizedSelector( export const selectListImagesQueryArgs = createMemoizedSelector(
selectGallerySlice, selectGallerySlice,
(gallery) => { (gallery): ListImagesArgs => ({
const { selectedBoardId, galleryView } = gallery; board_id: gallery.selectedBoardId,
const categories = categories:
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
offset: gallery.offset,
const listImagesBaseQueryArgs: ListImagesArgs = { limit: gallery.limit,
board_id: selectedBoardId, is_intermediate: false,
categories, })
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
is_intermediate: false,
};
return listImagesBaseQueryArgs;
}
); );

View File

@ -7,6 +7,7 @@ import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types'; import type { ImageDTO } from 'services/api/types';
import type { BoardId, GalleryState, GalleryView } from './types'; import type { BoardId, GalleryState, GalleryView } from './types';
import { IMAGE_LIMIT, INITIAL_IMAGE_LIMIT } from './types';
export const initialGalleryState: GalleryState = { export const initialGalleryState: GalleryState = {
selection: [], selection: [],
@ -17,6 +18,8 @@ export const initialGalleryState: GalleryState = {
selectedBoardId: 'none', selectedBoardId: 'none',
galleryView: 'images', galleryView: 'images',
boardSearchText: '', boardSearchText: '',
limit: INITIAL_IMAGE_LIMIT,
offset: 0,
}; };
export const gallerySlice = createSlice({ export const gallerySlice = createSlice({
@ -44,6 +47,8 @@ export const gallerySlice = createSlice({
) => { ) => {
state.selectedBoardId = action.payload.boardId; state.selectedBoardId = action.payload.boardId;
state.galleryView = 'images'; state.galleryView = 'images';
state.offset = 0;
state.limit = INITIAL_IMAGE_LIMIT;
}, },
autoAddBoardIdChanged: (state, action: PayloadAction<BoardId>) => { autoAddBoardIdChanged: (state, action: PayloadAction<BoardId>) => {
if (!action.payload) { if (!action.payload) {
@ -54,10 +59,21 @@ export const gallerySlice = createSlice({
}, },
galleryViewChanged: (state, action: PayloadAction<GalleryView>) => { galleryViewChanged: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload; state.galleryView = action.payload;
state.offset = 0;
state.limit = INITIAL_IMAGE_LIMIT;
}, },
boardSearchTextChanged: (state, action: PayloadAction<string>) => { boardSearchTextChanged: (state, action: PayloadAction<string>) => {
state.boardSearchText = action.payload; 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) => { extraReducers: (builder) => {
builder.addMatcher(isAnyBoardDeleted, (state, action) => { builder.addMatcher(isAnyBoardDeleted, (state, action) => {
@ -96,6 +112,7 @@ export const {
galleryViewChanged, galleryViewChanged,
selectionChanged, selectionChanged,
boardSearchTextChanged, boardSearchTextChanged,
moreImagesLoaded,
} = gallerySlice.actions; } = gallerySlice.actions;
export default gallerySlice.reducer; export default gallerySlice.reducer;

View File

@ -22,4 +22,6 @@ export type GalleryState = {
selectedBoardId: BoardId; selectedBoardId: BoardId;
galleryView: GalleryView; galleryView: GalleryView;
boardSearchText: string; boardSearchText: string;
offset: number;
limit: number;
}; };