mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): improve IAIDndImage performance
`dnd-kit` has a problem where, when drag events start and stop, every item that uses the library rerenders. This occurs due to its use of context. The dnd library needs to listen for pointer events to handle dragging. Because our images are both clickable (selectable) and draggable, every time you click an image, the dnd necessarily sees this event, its context updates and all other dnd-enabled components rerender. With a lot of images in gallery and/or batch manager, this leads to some jank. There is an open PR to address this: https://github.com/clauderic/dnd-kit/pull/1096 But unfortunately, the maintainer hasn't accepted any changes for a few months, and its not clear if this will be merged any time soon :/ This change simply extracts the draggable and droppable logic out of IAIDndImage into their own minimal components. Now only these need to rerender when the dnd context is changed. The rerenders are far less impactful now. Hopefully the linked PR is accepted and we get even more efficient dnd functionality in the future. Also changed dnd activation constraint to distance (currently 10px) instead of delay and updated the stacking context of IAIDndImage subcomponents so that the reset and upload buttons still work.
This commit is contained in:
@@ -6,18 +6,18 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { snapCenterToCursor } from '@dnd-kit/modifiers';
|
||||
import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { PropsWithChildren, memo, useCallback, useState } from 'react';
|
||||
import DragPreview from './DragPreview';
|
||||
import { snapCenterToCursor } from '@dnd-kit/modifiers';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
TypesafeDraggableData,
|
||||
} from './typesafeDnd';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
|
||||
|
||||
type ImageDndContextProps = PropsWithChildren;
|
||||
|
||||
@@ -49,11 +49,11 @@ const ImageDndContext = (props: ImageDndContextProps) => {
|
||||
);
|
||||
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 5 },
|
||||
activationConstraint: { distance: 10 },
|
||||
});
|
||||
|
||||
const touchSensor = useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 5 },
|
||||
activationConstraint: { distance: 10 },
|
||||
});
|
||||
|
||||
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
ChakraProps,
|
||||
Flex,
|
||||
Icon,
|
||||
@@ -10,9 +9,6 @@ import {
|
||||
import {
|
||||
TypesafeDraggableData,
|
||||
TypesafeDroppableData,
|
||||
isValidDrop,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import {
|
||||
@@ -21,14 +17,13 @@ import {
|
||||
} from 'common/components/IAIImageFallback';
|
||||
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { MouseEvent, ReactElement, SyntheticEvent, memo, useRef } from 'react';
|
||||
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
|
||||
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
|
||||
import { PostUploadAction } from 'services/api/thunks/image';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import IAIDropOverlay from './IAIDropOverlay';
|
||||
import IAIDraggable from './IAIDraggable';
|
||||
import IAIDroppable from './IAIDroppable';
|
||||
|
||||
type IAIDndImageProps = {
|
||||
imageDTO: ImageDTO | undefined;
|
||||
@@ -144,30 +139,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
||||
}}
|
||||
/>
|
||||
{withMetadataOverlay && <ImageMetadataOverlay image={imageDTO} />}
|
||||
{onClickReset && withResetIcon && (
|
||||
<IAIIconButton
|
||||
onClick={onClickReset}
|
||||
aria-label={resetTooltip}
|
||||
tooltip={resetTooltip}
|
||||
icon={resetIcon}
|
||||
size="sm"
|
||||
variant="link"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 1,
|
||||
insetInlineEnd: 1,
|
||||
p: 0,
|
||||
minW: 0,
|
||||
svg: {
|
||||
transitionProperty: 'common',
|
||||
transitionDuration: 'normal',
|
||||
fill: 'base.100',
|
||||
_hover: { fill: 'base.50' },
|
||||
filter: resetIconShadow,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{!imageDTO && !isUploadDisabled && (
|
||||
@@ -198,84 +169,44 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
||||
</>
|
||||
)}
|
||||
{!imageDTO && isUploadDisabled && noContentFallback}
|
||||
<Droppable
|
||||
<IAIDroppable
|
||||
data={droppableData}
|
||||
disabled={isDropDisabled}
|
||||
dropLabel={dropLabel}
|
||||
/>
|
||||
<Draggable
|
||||
data={draggableData}
|
||||
disabled={isDragDisabled || !imageDTO}
|
||||
onClick={onClick}
|
||||
/>
|
||||
{imageDTO && (
|
||||
<IAIDraggable
|
||||
data={draggableData}
|
||||
disabled={isDragDisabled || !imageDTO}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
{onClickReset && withResetIcon && imageDTO && (
|
||||
<IAIIconButton
|
||||
onClick={onClickReset}
|
||||
aria-label={resetTooltip}
|
||||
tooltip={resetTooltip}
|
||||
icon={resetIcon}
|
||||
size="sm"
|
||||
variant="link"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 1,
|
||||
insetInlineEnd: 1,
|
||||
p: 0,
|
||||
minW: 0,
|
||||
svg: {
|
||||
transitionProperty: 'common',
|
||||
transitionDuration: 'normal',
|
||||
fill: 'base.100',
|
||||
_hover: { fill: 'base.50' },
|
||||
filter: resetIconShadow,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(IAIDndImage);
|
||||
|
||||
type DroppableProps = {
|
||||
dropLabel?: string;
|
||||
disabled?: boolean;
|
||||
data?: TypesafeDroppableData;
|
||||
};
|
||||
|
||||
const Droppable = memo((props: DroppableProps) => {
|
||||
const { dropLabel, data, disabled } = props;
|
||||
const dndId = useRef(uuidv4());
|
||||
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: dndId.current,
|
||||
disabled,
|
||||
data,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
position="absolute"
|
||||
w="full"
|
||||
h="full"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isValidDrop(data, active) && (
|
||||
<IAIDropOverlay isOver={isOver} label={dropLabel} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
Droppable.displayName = 'Droppable';
|
||||
|
||||
type DraggableProps = {
|
||||
disabled?: boolean;
|
||||
data?: TypesafeDraggableData;
|
||||
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
const Draggable = memo((props: DraggableProps) => {
|
||||
const { data, disabled, onClick } = props;
|
||||
const dndId = useRef(uuidv4());
|
||||
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: dndId.current,
|
||||
disabled,
|
||||
data,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
ref={setNodeRef}
|
||||
position="absolute"
|
||||
w="full"
|
||||
h="full"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Draggable.displayName = 'Draggable';
|
||||
|
||||
38
invokeai/frontend/web/src/common/components/IAIDraggable.tsx
Normal file
38
invokeai/frontend/web/src/common/components/IAIDraggable.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import {
|
||||
TypesafeDraggableData,
|
||||
useDraggable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { MouseEvent, memo, useRef } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type IAIDraggableProps = {
|
||||
disabled?: boolean;
|
||||
data?: TypesafeDraggableData;
|
||||
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
const IAIDraggable = (props: IAIDraggableProps) => {
|
||||
const { data, disabled, onClick } = props;
|
||||
const dndId = useRef(uuidv4());
|
||||
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: dndId.current,
|
||||
disabled,
|
||||
data,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
ref={setNodeRef}
|
||||
position="absolute"
|
||||
w="full"
|
||||
h="full"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(IAIDraggable);
|
||||
45
invokeai/frontend/web/src/common/components/IAIDroppable.tsx
Normal file
45
invokeai/frontend/web/src/common/components/IAIDroppable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import {
|
||||
TypesafeDroppableData,
|
||||
isValidDrop,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { memo, useRef } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import IAIDropOverlay from './IAIDropOverlay';
|
||||
|
||||
type IAIDroppableProps = {
|
||||
dropLabel?: string;
|
||||
disabled?: boolean;
|
||||
data?: TypesafeDroppableData;
|
||||
};
|
||||
|
||||
const IAIDroppable = (props: IAIDroppableProps) => {
|
||||
const { dropLabel, data, disabled } = props;
|
||||
const dndId = useRef(uuidv4());
|
||||
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: dndId.current,
|
||||
disabled,
|
||||
data,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
position="absolute"
|
||||
w="full"
|
||||
h="full"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isValidDrop(data, active) && (
|
||||
<IAIDropOverlay isOver={isOver} label={dropLabel} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(IAIDroppable);
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
import IAIDroppable from 'common/components/IAIDroppable';
|
||||
import BatchImageGrid from './BatchImageGrid';
|
||||
import IAIDropOverlay from 'common/components/IAIDropOverlay';
|
||||
import {
|
||||
AddToBatchDropData,
|
||||
isValidDrop,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
|
||||
const droppableData: AddToBatchDropData = {
|
||||
id: 'batch',
|
||||
@@ -13,17 +9,10 @@ const droppableData: AddToBatchDropData = {
|
||||
};
|
||||
|
||||
const BatchImageContainer = () => {
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: 'batch-manager',
|
||||
data: droppableData,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box ref={setNodeRef} position="relative" w="full" h="full">
|
||||
<Box position="relative" w="full" h="full">
|
||||
<BatchImageGrid />
|
||||
{isValidDrop(droppableData, active) && (
|
||||
<IAIDropOverlay isOver={isOver} label="Add to Batch" />
|
||||
)}
|
||||
<IAIDroppable data={droppableData} dropLabel="Add to Batch" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { Flex, useColorMode } from '@chakra-ui/react';
|
||||
import { FaImages } from 'react-icons/fa';
|
||||
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
import IAIDroppable from 'common/components/IAIDroppable';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import IAIDropOverlay from 'common/components/IAIDropOverlay';
|
||||
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { FaImages } from 'react-icons/fa';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import {
|
||||
MoveBoardDropData,
|
||||
isValidDrop,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
|
||||
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -26,11 +21,6 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
context: { boardId: null },
|
||||
};
|
||||
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: `board_droppable_all_images`,
|
||||
data: droppableData,
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -44,7 +34,6 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
ref={setNodeRef}
|
||||
onClick={handleAllImagesBoardClick}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
@@ -67,11 +56,7 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{isValidDrop(droppableData, active) && (
|
||||
<IAIDropOverlay isOver={isOver} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<IAIDroppable data={droppableData} />
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
|
||||
@@ -12,28 +12,23 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import { FaFolder, FaTrash } from 'react-icons/fa';
|
||||
import { ContextMenu } from 'chakra-ui-contextmenu';
|
||||
import { BoardDTO } from 'services/api/types';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { memo, useCallback, useContext, useMemo } from 'react';
|
||||
import { FaFolder, FaTrash } from 'react-icons/fa';
|
||||
import {
|
||||
useDeleteBoardMutation,
|
||||
useUpdateBoardMutation,
|
||||
} from 'services/api/endpoints/boards';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { BoardDTO } from 'services/api/types';
|
||||
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import IAIDropOverlay from 'common/components/IAIDropOverlay';
|
||||
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
|
||||
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
import IAIDroppable from 'common/components/IAIDroppable';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import {
|
||||
MoveBoardDropData,
|
||||
isValidDrop,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
|
||||
|
||||
interface HoverableBoardProps {
|
||||
board: BoardDTO;
|
||||
@@ -76,16 +71,14 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
onClickDeleteBoardImages(board);
|
||||
}, [board, onClickDeleteBoardImages]);
|
||||
|
||||
const droppableData: MoveBoardDropData = {
|
||||
id: board_id,
|
||||
actionType: 'MOVE_BOARD',
|
||||
context: { boardId: board_id },
|
||||
};
|
||||
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: `board_droppable_${board_id}`,
|
||||
data: droppableData,
|
||||
});
|
||||
const droppableData: MoveBoardDropData = useMemo(
|
||||
() => ({
|
||||
id: board_id,
|
||||
actionType: 'MOVE_BOARD',
|
||||
context: { boardId: board_id },
|
||||
}),
|
||||
[board_id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ touchAction: 'none', height: 'full' }}>
|
||||
@@ -127,7 +120,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
ref={setNodeRef}
|
||||
onClick={handleSelectBoard}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
@@ -167,11 +159,7 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
>
|
||||
<Badge variant="solid">{board.image_count}</Badge>
|
||||
</Flex>
|
||||
<AnimatePresence>
|
||||
{isValidDrop(droppableData, active) && (
|
||||
<IAIDropOverlay isOver={isOver} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<IAIDroppable data={droppableData} />
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
|
||||
Reference in New Issue
Block a user