Skip to content

Commit 23c8a89

Browse files
psychedelicioushipsterusername
authored andcommitted
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.
1 parent 7d93329 commit 23c8a89

File tree

15 files changed

+193
-210
lines changed

15 files changed

+193
-210
lines changed

invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
UnknownAction,
66
} from '@reduxjs/toolkit';
77
import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
8+
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
89
import type { AppDispatch, RootState } from 'app/store/store';
910

1011
import { addCommitStagingAreaImageListener } from './listeners/addCommitStagingAreaImageListener';
@@ -117,6 +118,9 @@ addImageToDeleteSelectedListener();
117118
addImagesStarredListener();
118119
addImagesUnstarredListener();
119120

121+
// Gallery
122+
addGalleryImageClickedListener();
123+
120124
// User Invoked
121125
addEnqueueRequestedCanvasListener();
122126
addEnqueueRequestedNodes();
@@ -135,19 +139,7 @@ addCanvasMergedListener();
135139
addStagingAreaImageSavedListener();
136140
addCommitStagingAreaImageListener();
137141

138-
/**
139-
* Socket.IO Events - these handle SIO events directly and pass on internal application actions.
140-
* We don't handle SIO events in slices via `extraReducers` because some of these events shouldn't
141-
* actually be handled at all.
142-
*
143-
* For example, we don't want to respond to progress events for canceled sessions. To avoid
144-
* duplicating the logic to determine if an event should be responded to, we handle all of that
145-
* "is this session canceled?" logic in these listeners.
146-
*
147-
* The `socketGeneratorProgress` listener will then only dispatch the `appSocketGeneratorProgress`
148-
* action if it should be handled by the rest of the application. It is this `appSocketGeneratorProgress`
149-
* action that is handled by reducers in slices.
150-
*/
142+
// Socket.IO
151143
addGeneratorProgressListener();
152144
addGraphExecutionStateCompleteListener();
153145
addInvocationCompleteListener();
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createAction } from '@reduxjs/toolkit';
2+
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
3+
import { selectionChanged } from 'features/gallery/store/gallerySlice';
4+
import { imagesApi } from 'services/api/endpoints/images';
5+
import type { ImageDTO } from 'services/api/types';
6+
import { imagesSelectors } from 'services/api/util';
7+
8+
import { startAppListening } from '..';
9+
10+
export const galleryImageClicked = createAction<{
11+
imageDTO: ImageDTO;
12+
shiftKey: boolean;
13+
ctrlKey: boolean;
14+
metaKey: boolean;
15+
}>('gallery/imageClicked');
16+
17+
/**
18+
* This listener handles the logic for selecting images in the gallery.
19+
*
20+
* Previously, this logic was in a `useCallback` with the whole gallery selection as a dependency. Every time
21+
* the selection changed, the callback got recreated and all images rerendered. This could easily block for
22+
* hundreds of ms, more for lower end devices.
23+
*
24+
* Moving this logic into a listener means we don't need to recalculate anything dynamically and the gallery
25+
* is much more responsive.
26+
*/
27+
28+
export const addGalleryImageClickedListener = () => {
29+
startAppListening({
30+
actionCreator: galleryImageClicked,
31+
effect: async (action, { dispatch, getState }) => {
32+
const { imageDTO, shiftKey, ctrlKey, metaKey } = action.payload;
33+
const state = getState();
34+
const queryArgs = selectListImagesQueryArgs(state);
35+
const { data: listImagesData } =
36+
imagesApi.endpoints.listImages.select(queryArgs)(state);
37+
38+
if (!listImagesData) {
39+
// Should never happen if we have clicked a gallery image
40+
return;
41+
}
42+
43+
const imageDTOs = imagesSelectors.selectAll(listImagesData);
44+
const selection = state.gallery.selection;
45+
46+
if (shiftKey) {
47+
const rangeEndImageName = imageDTO.image_name;
48+
const lastSelectedImage = selection[selection.length - 1]?.image_name;
49+
const lastClickedIndex = imageDTOs.findIndex(
50+
(n) => n.image_name === lastSelectedImage
51+
);
52+
const currentClickedIndex = imageDTOs.findIndex(
53+
(n) => n.image_name === rangeEndImageName
54+
);
55+
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
56+
// We have a valid range!
57+
const start = Math.min(lastClickedIndex, currentClickedIndex);
58+
const end = Math.max(lastClickedIndex, currentClickedIndex);
59+
const imagesToSelect = imageDTOs.slice(start, end + 1);
60+
dispatch(selectionChanged(selection.concat(imagesToSelect)));
61+
}
62+
} else if (ctrlKey || metaKey) {
63+
if (
64+
selection.some((i) => i.image_name === imageDTO.image_name) &&
65+
selection.length > 1
66+
) {
67+
dispatch(
68+
selectionChanged(
69+
selection.filter((n) => n.image_name !== imageDTO.image_name)
70+
)
71+
);
72+
} else {
73+
dispatch(selectionChanged(selection.concat(imageDTO)));
74+
}
75+
} else {
76+
dispatch(selectionChanged([imageDTO]));
77+
}
78+
},
79+
});
80+
};

invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeleted.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { isControlNetOrT2IAdapter } from 'features/controlAdapters/store/types';
99
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
1010
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
11-
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
11+
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
1212
import { imageSelected } from 'features/gallery/store/gallerySlice';
1313
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
1414
import { isImageFieldInputInstance } from 'features/nodes/types/field';
@@ -49,7 +49,7 @@ export const addRequestedSingleImageDeletionListener = () => {
4949
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
5050
const { image_name } = imageDTO;
5151

52-
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
52+
const baseQueryArgs = selectListImagesQueryArgs(state);
5353
const { data } =
5454
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
5555

@@ -180,9 +180,9 @@ export const addRequestedMultipleImageDeletionListener = () => {
180180
imagesApi.endpoints.deleteImages.initiate({ imageDTOs })
181181
).unwrap();
182182
const state = getState();
183-
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
183+
const queryArgs = selectListImagesQueryArgs(state);
184184
const { data } =
185-
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
185+
imagesApi.endpoints.listImages.select(queryArgs)(state);
186186

187187
const newSelectedImageDTO = data
188188
? imagesSelectors.selectAll(data)[0]

invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type {
1212
} from 'features/dnd/types';
1313
import { imageSelected } from 'features/gallery/store/gallerySlice';
1414
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
15-
import { workflowExposedFieldAdded } from 'features/nodes/store/workflowSlice';
1615
import {
1716
initialImageChanged,
1817
selectOptimalDimension,
@@ -35,10 +34,10 @@ export const addImageDroppedListener = () => {
3534

3635
if (activeData.payloadType === 'IMAGE_DTO') {
3736
log.debug({ activeData, overData }, 'Image dropped');
38-
} else if (activeData.payloadType === 'IMAGE_DTOS') {
37+
} else if (activeData.payloadType === 'GALLERY_SELECTION') {
3938
log.debug(
4039
{ activeData, overData },
41-
`Images (${activeData.payload.imageDTOs.length}) dropped`
40+
`Images (${getState().gallery.selection.length}) dropped`
4241
);
4342
} else if (activeData.payloadType === 'NODE_FIELD') {
4443
log.debug(
@@ -49,19 +48,6 @@ export const addImageDroppedListener = () => {
4948
log.debug({ activeData, overData }, `Unknown payload dropped`);
5049
}
5150

52-
if (
53-
overData.actionType === 'ADD_FIELD_TO_LINEAR' &&
54-
activeData.payloadType === 'NODE_FIELD'
55-
) {
56-
const { nodeId, field } = activeData.payload;
57-
dispatch(
58-
workflowExposedFieldAdded({
59-
nodeId,
60-
fieldName: field.name,
61-
})
62-
);
63-
}
64-
6551
/**
6652
* Image dropped on current image
6753
*/
@@ -207,10 +193,9 @@ export const addImageDroppedListener = () => {
207193
*/
208194
if (
209195
overData.actionType === 'ADD_TO_BOARD' &&
210-
activeData.payloadType === 'IMAGE_DTOS' &&
211-
activeData.payload.imageDTOs
196+
activeData.payloadType === 'GALLERY_SELECTION'
212197
) {
213-
const { imageDTOs } = activeData.payload;
198+
const imageDTOs = getState().gallery.selection;
214199
const { boardId } = overData.context;
215200
dispatch(
216201
imagesApi.endpoints.addImagesToBoard.initiate({
@@ -226,10 +211,9 @@ export const addImageDroppedListener = () => {
226211
*/
227212
if (
228213
overData.actionType === 'REMOVE_FROM_BOARD' &&
229-
activeData.payloadType === 'IMAGE_DTOS' &&
230-
activeData.payload.imageDTOs
214+
activeData.payloadType === 'GALLERY_SELECTION'
231215
) {
232-
const { imageDTOs } = activeData.payload;
216+
const imageDTOs = getState().gallery.selection;
233217
dispatch(
234218
imagesApi.endpoints.removeImagesFromBoard.initiate({
235219
imageDTOs,

invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ChakraProps } from '@chakra-ui/react';
22
import { Box, Flex, Heading, Image } from '@chakra-ui/react';
3+
import { useAppSelector } from 'app/store/storeHooks';
34
import { InvText } from 'common/components/InvText/wrapper';
45
import type { TypesafeDraggableData } from 'features/dnd/types';
56
import { memo } from 'react';
@@ -34,6 +35,7 @@ const multiImageStyles: ChakraProps['sx'] = {
3435

3536
const DragPreview = (props: OverlayDragImageProps) => {
3637
const { t } = useTranslation();
38+
const selectionCount = useAppSelector((s) => s.gallery.selection.length);
3739
if (!props.dragData) {
3840
return null;
3941
}
@@ -79,10 +81,10 @@ const DragPreview = (props: OverlayDragImageProps) => {
7981
);
8082
}
8183

82-
if (props.dragData.payloadType === 'IMAGE_DTOS') {
84+
if (props.dragData.payloadType === 'GALLERY_SELECTION') {
8385
return (
8486
<Flex sx={multiImageStyles}>
85-
<Heading>{props.dragData.payload.imageDTOs.length}</Heading>
87+
<Heading>{selectionCount}</Heading>
8688
<Heading size="sm">{t('parameters.images')}</Heading>
8789
</Flex>
8890
);

invokeai/frontend/web/src/features/dnd/types/index.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
useDroppable as useOriginalDroppable,
1111
UseDroppableArguments,
1212
} from '@dnd-kit/core';
13+
import type { BoardId } from 'features/gallery/store/types';
1314
import type {
1415
FieldInputInstance,
1516
FieldInputTemplate,
@@ -51,15 +52,6 @@ export type NodesImageDropData = BaseDropData & {
5152
};
5253
};
5354

54-
export type NodesMultiImageDropData = BaseDropData & {
55-
actionType: 'SET_MULTI_NODES_IMAGE';
56-
context: { nodeId: string; fieldName: string };
57-
};
58-
59-
export type AddToBatchDropData = BaseDropData & {
60-
actionType: 'ADD_TO_BATCH';
61-
};
62-
6355
export type AddToBoardDropData = BaseDropData & {
6456
actionType: 'ADD_TO_BOARD';
6557
context: { boardId: string };
@@ -69,21 +61,14 @@ export type RemoveFromBoardDropData = BaseDropData & {
6961
actionType: 'REMOVE_FROM_BOARD';
7062
};
7163

72-
export type AddFieldToLinearViewDropData = BaseDropData & {
73-
actionType: 'ADD_FIELD_TO_LINEAR';
74-
};
75-
7664
export type TypesafeDroppableData =
7765
| CurrentImageDropData
7866
| InitialImageDropData
7967
| ControlAdapterDropData
8068
| CanvasInitialImageDropData
8169
| NodesImageDropData
82-
| AddToBatchDropData
83-
| NodesMultiImageDropData
8470
| AddToBoardDropData
85-
| RemoveFromBoardDropData
86-
| AddFieldToLinearViewDropData;
71+
| RemoveFromBoardDropData;
8772

8873
type BaseDragData = {
8974
id: string;
@@ -103,15 +88,15 @@ export type ImageDraggableData = BaseDragData & {
10388
payload: { imageDTO: ImageDTO };
10489
};
10590

106-
export type ImageDTOsDraggableData = BaseDragData & {
107-
payloadType: 'IMAGE_DTOS';
108-
payload: { imageDTOs: ImageDTO[] };
91+
export type GallerySelectionDraggableData = BaseDragData & {
92+
payloadType: 'GALLERY_SELECTION';
93+
payload: { boardId: BoardId };
10994
};
11095

11196
export type TypesafeDraggableData =
11297
| NodeFieldDraggableData
11398
| ImageDraggableData
114-
| ImageDTOsDraggableData;
99+
| GallerySelectionDraggableData;
115100

116101
export interface UseDroppableTypesafeArguments
117102
extends Omit<UseDroppableArguments, 'data'> {

invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ export const isValidDrop = (
1616
}
1717

1818
switch (actionType) {
19-
case 'ADD_FIELD_TO_LINEAR':
20-
return payloadType === 'NODE_FIELD';
2119
case 'SET_CURRENT_IMAGE':
2220
return payloadType === 'IMAGE_DTO';
2321
case 'SET_INITIAL_IMAGE':
@@ -28,15 +26,13 @@ export const isValidDrop = (
2826
return payloadType === 'IMAGE_DTO';
2927
case 'SET_NODES_IMAGE':
3028
return payloadType === 'IMAGE_DTO';
31-
case 'SET_MULTI_NODES_IMAGE':
32-
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
33-
case 'ADD_TO_BATCH':
34-
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
3529
case 'ADD_TO_BOARD': {
3630
// If the board is the same, don't allow the drop
3731

3832
// Check the payload types
39-
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
33+
const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
34+
payloadType
35+
);
4036
if (!isPayloadValid) {
4137
return false;
4238
}
@@ -50,12 +46,10 @@ export const isValidDrop = (
5046
return currentBoard !== destinationBoard;
5147
}
5248

53-
if (payloadType === 'IMAGE_DTOS') {
49+
if (payloadType === 'GALLERY_SELECTION') {
5450
// Assume all images are on the same board - this is true for the moment
55-
const { imageDTOs } = active.data.current.payload;
56-
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
51+
const currentBoard = active.data.current.payload.boardId;
5752
const destinationBoard = overData.context.boardId;
58-
5953
return currentBoard !== destinationBoard;
6054
}
6155

@@ -65,7 +59,9 @@ export const isValidDrop = (
6559
// If the board is the same, don't allow the drop
6660

6761
// Check the payload types
68-
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
62+
const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(
63+
payloadType
64+
);
6965
if (!isPayloadValid) {
7066
return false;
7167
}
@@ -78,11 +74,8 @@ export const isValidDrop = (
7874
return currentBoard !== 'none';
7975
}
8076

81-
if (payloadType === 'IMAGE_DTOS') {
82-
// Assume all images are on the same board - this is true for the moment
83-
const { imageDTOs } = active.data.current.payload;
84-
const currentBoard = imageDTOs[0]?.board_id ?? 'none';
85-
77+
if (payloadType === 'GALLERY_SELECTION') {
78+
const currentBoard = active.data.current.payload.boardId;
8679
return currentBoard !== 'none';
8780
}
8881

0 commit comments

Comments
 (0)