From fb4ca3f66e06da1fa426cb60404113a1799dfbaa Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 19:57:27 +1000
Subject: [PATCH 01/11] feat(ui): use singleton context menu
This improves render perf for the image component by 10-20%.
---
.../frontend/web/src/app/components/App.tsx | 2 +
.../web/src/common/components/IAIDndImage.tsx | 134 ++++++-----
.../ImageContextMenu/ImageContextMenu.tsx | 222 ++++++++++++++++--
3 files changed, 270 insertions(+), 88 deletions(-)
diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx
index 247554fd2d9..284690928a1 100644
--- a/invokeai/frontend/web/src/app/components/App.tsx
+++ b/invokeai/frontend/web/src/app/components/App.tsx
@@ -20,6 +20,7 @@ import {
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
+import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
@@ -120,6 +121,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
+
);
};
diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
index f4b85736c5c..11fb2ae34f0 100644
--- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
+++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
@@ -4,9 +4,9 @@ import { IAILoadingImageFallback, IAINoContentFallback } from 'common/components
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
-import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
+import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import type { MouseEvent, ReactElement, ReactNode, SyntheticEvent } from 'react';
-import { memo, useCallback, useMemo } from 'react';
+import { memo, useCallback, useMemo, useRef } from 'react';
import { PiImageBold, PiUploadSimpleBold } from 'react-icons/pi';
import type { ImageDTO, PostUploadAction } from 'services/api/types';
@@ -17,7 +17,14 @@ const defaultUploadElement = ;
const defaultNoContentFallback = ;
+const baseStyles: SystemStyleObject = {
+ touchAction: 'none',
+ userSelect: 'none',
+ webkitUserSelect: 'none',
+};
+
const sx: SystemStyleObject = {
+ ...baseStyles,
'.gallery-image-container::before': {
content: '""',
display: 'inline-block',
@@ -168,75 +175,74 @@ const IAIDndImage = (props: IAIDndImageProps) => {
[imageDTO]
);
+ const ref = useRef(null);
+ useImageContextMenu(imageDTO, ref);
+
return (
-
- {(ref) => (
+
+ {imageDTO && (
- {imageDTO && (
-
- }
- onError={onError}
- draggable={false}
- w={imageDTO.width}
- objectFit="contain"
- maxW="full"
- maxH="full"
- borderRadius="base"
- sx={imageSx}
- data-testid={dataTestId}
- />
- {withMetadataOverlay && }
-
- )}
- {!imageDTO && !isUploadDisabled && (
- <>
-
-
- {uploadElement}
-
- >
- )}
- {!imageDTO && isUploadDisabled && noContentFallback}
- {imageDTO && !isDragDisabled && (
-
- )}
- {children}
- {!isDropDisabled && }
+ }
+ onError={onError}
+ draggable={false}
+ w={imageDTO.width}
+ objectFit="contain"
+ maxW="full"
+ maxH="full"
+ borderRadius="base"
+ sx={imageSx}
+ data-testid={dataTestId}
+ />
+ {withMetadataOverlay && }
)}
-
+ {!imageDTO && !isUploadDisabled && (
+ <>
+
+
+ {uploadElement}
+
+ >
+ )}
+ {!imageDTO && isUploadDisabled && noContentFallback}
+ {imageDTO && !isDragDisabled && (
+
+ )}
+ {children}
+ {!isDropDisabled && }
+
);
};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
index e75687ba648..a1aa5649b28 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
@@ -1,42 +1,216 @@
-import type { ContextMenuProps } from '@invoke-ai/ui-library';
-import { ContextMenu, MenuList } from '@invoke-ai/ui-library';
+import type { ChakraProps } from '@invoke-ai/ui-library';
+import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
+import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import MultipleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems';
+import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems';
import { selectSelectionCount } from 'features/gallery/store/gallerySelectors';
-import { memo, useCallback } from 'react';
+import { map } from 'nanostores';
+import type { RefObject } from 'react';
+import { memo, useCallback, useEffect, useRef } from 'react';
import type { ImageDTO } from 'services/api/types';
-import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
-import SingleSelectionMenuItems from './SingleSelectionMenuItems';
+const LONGPRESS_DELAY_MS = 500;
+const LONGPRESS_MOVE_THRESHOLD_PX = 10;
-type Props = {
- imageDTO: ImageDTO | undefined;
- children: ContextMenuProps['children'];
+type ImageContextMenuState = {
+ isOpen: boolean;
+ imageDTO: ImageDTO | null;
+ position: { x: number; y: number };
};
-const ImageContextMenu = ({ imageDTO, children }: Props) => {
- const selectionCount = useAppSelector(selectSelectionCount);
+const $imageContextMenuState = map({
+ isOpen: false,
+ imageDTO: null,
+ position: { x: -1, y: -1 },
+});
+const onClose = () => {
+ $imageContextMenuState.setKey('isOpen', false);
+};
+
+export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: RefObject) => {
+ const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 });
+ const longPressTimeoutRef = useRef(0);
+ const animationTimeoutRef = useRef(0);
+
+ const activate = useCallback(
+ (x: number, y: number) => {
+ $imageContextMenuState.set({
+ isOpen: true,
+ position: { x, y },
+ imageDTO: imageDTO ?? null,
+ });
+ },
+ [imageDTO]
+ );
+
+ const onContextMenu = useCallback(
+ (e: MouseEvent | PointerEvent) => {
+ if (e.shiftKey) {
+ onClose();
+ return;
+ }
+
+ if (targetRef.current?.contains(e.target as Node) || e.target === targetRef.current) {
+ // clear pending delayed open
+ window.clearTimeout(animationTimeoutRef.current);
+ e.preventDefault();
+ if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) {
+ // if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
+ if ($imageContextMenuState.get().isOpen) {
+ onClose();
+ }
+ animationTimeoutRef.current = window.setTimeout(() => {
+ activate(e.pageX, e.pageY);
+ }, 100);
+ } else {
+ // else we can just open the menu at the current position
+ activate(e.pageX, e.pageY);
+ }
+ }
+
+ lastPositionRef.current = { x: e.pageX, y: e.pageY };
+ },
+ [activate, targetRef]
+ );
+
+ // Use a long press to open the context menu on touch devices
+ const onPointerDown = useCallback(
+ (e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ // Bail out if it's a mouse event - this is for touch/pen only
+ return;
+ }
+
+ longPressTimeoutRef.current = window.setTimeout(() => {
+ onContextMenu(e);
+ }, LONGPRESS_DELAY_MS);
- const renderMenuFunc = useCallback(() => {
- if (!imageDTO) {
- return null;
+ lastPositionRef.current = { x: e.pageX, y: e.pageY };
+ },
+ [onContextMenu]
+ );
+
+ const onPointerMove = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ // Bail out if it's a mouse event - this is for touch/pen only
+ return;
+ }
+ if (longPressTimeoutRef.current === null) {
+ return;
}
- if (selectionCount > 1) {
- return (
-
-
-
- );
+ const lastPosition = lastPositionRef.current;
+
+ const distanceFromLastPosition = Math.hypot(e.pageX - lastPosition.x, e.pageY - lastPosition.y);
+
+ if (distanceFromLastPosition > LONGPRESS_MOVE_THRESHOLD_PX) {
+ clearTimeout(longPressTimeoutRef.current);
}
+ }, []);
+ const onPointerUp = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ // Bail out if it's a mouse event - this is for touch/pen only
+ return;
+ }
+ if (longPressTimeoutRef.current) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ const onPointerCancel = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ // Bail out if it's a mouse event - this is for touch/pen only
+ return;
+ }
+ if (longPressTimeoutRef.current) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!targetRef.current) {
+ return;
+ }
+
+ const controller = new AbortController();
+
+ // Context menu events
+ window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal });
+
+ // Long press events
+ targetRef.current.addEventListener('pointerdown', onPointerDown, { signal: controller.signal });
+ targetRef.current.addEventListener('pointerup', onPointerUp, { signal: controller.signal });
+ targetRef.current.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal });
+ targetRef.current.addEventListener('pointermove', onPointerMove, { signal: controller.signal });
+
+ return () => {
+ controller.abort();
+ };
+ }, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp, targetRef]);
+
+ useEffect(
+ () => () => {
+ window.clearTimeout(animationTimeoutRef.current);
+ window.clearTimeout(longPressTimeoutRef.current);
+ },
+ []
+ );
+};
+
+export const ImageContextMenu = memo(() => {
+ useAssertSingleton('ImageContextMenu');
+ const state = useStore($imageContextMenuState);
+ useGlobalMenuClose(onClose);
+
+ return (
+
+
+
+ );
+});
+
+ImageContextMenu.displayName = 'ImageContextMenu';
+
+const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
+
+const MenuContent = memo(() => {
+ const selectionCount = useAppSelector(selectSelectionCount);
+ const state = useStore($imageContextMenuState);
+
+ if (!state.imageDTO) {
+ return null;
+ }
+
+ if (selectionCount > 1) {
return (
-
+
);
- }, [imageDTO, selectionCount]);
+ }
- return {children};
-};
+ return (
+
+
+
+ );
+});
-export default memo(ImageContextMenu);
+MenuContent.displayName = 'MenuContent';
From 901c3573f6e012fb63e794c8f326f8659cf93216 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 19:58:31 +1000
Subject: [PATCH 02/11] perf(ui): do not call upload hook unless upload is
needed
---
.../web/src/common/components/IAIDndImage.tsx | 95 ++++++++++++-------
1 file changed, 59 insertions(+), 36 deletions(-)
diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
index 11fb2ae34f0..e5306c14e35 100644
--- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
+++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
@@ -132,36 +132,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
[onMouseOut]
);
- const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
- postUploadAction,
- isDisabled: isUploadDisabled,
- });
-
- const uploadButtonStyles = useMemo(() => {
- const styles: SystemStyleObject = {
- minH: minSize,
- w: 'full',
- h: 'full',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 'base',
- transitionProperty: 'common',
- transitionDuration: '0.1s',
- color: 'base.500',
- };
- if (!isUploadDisabled) {
- Object.assign(styles, {
- cursor: 'pointer',
- bg: 'base.700',
- _hover: {
- bg: 'base.650',
- color: 'base.300',
- },
- });
- }
- return styles;
- }, [isUploadDisabled, minSize]);
-
const openInNewTab = useCallback(
(e: MouseEvent) => {
if (!imageDTO) {
@@ -224,12 +194,12 @@ const IAIDndImage = (props: IAIDndImageProps) => {
)}
{!imageDTO && !isUploadDisabled && (
- <>
-
-
- {uploadElement}
-
- >
+
)}
{!imageDTO && isUploadDisabled && noContentFallback}
{imageDTO && !isDragDisabled && (
@@ -247,3 +217,56 @@ const IAIDndImage = (props: IAIDndImageProps) => {
};
export default memo(IAIDndImage);
+
+const UploadButton = memo(
+ ({
+ isUploadDisabled,
+ postUploadAction,
+ uploadElement,
+ minSize,
+ }: {
+ isUploadDisabled: boolean;
+ postUploadAction?: PostUploadAction;
+ uploadElement: ReactNode;
+ minSize: number;
+ }) => {
+ const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
+ postUploadAction,
+ isDisabled: isUploadDisabled,
+ });
+
+ const uploadButtonStyles = useMemo(() => {
+ const styles: SystemStyleObject = {
+ minH: minSize,
+ w: 'full',
+ h: 'full',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: 'base',
+ transitionProperty: 'common',
+ transitionDuration: '0.1s',
+ color: 'base.500',
+ };
+ if (!isUploadDisabled) {
+ Object.assign(styles, {
+ cursor: 'pointer',
+ bg: 'base.700',
+ _hover: {
+ bg: 'base.650',
+ color: 'base.300',
+ },
+ });
+ }
+ return styles;
+ }, [isUploadDisabled, minSize]);
+
+ return (
+
+
+ {uploadElement}
+
+ );
+ }
+);
+
+UploadButton.displayName = 'UploadButton';
From 1557a2d044b7a423369d2cb08af6ef97e20e1db3 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 20:10:10 +1000
Subject: [PATCH 03/11] perf(ui): remove extraneous useCallbacks
---
.../web/src/common/components/IAIDndImage.tsx | 21 ++-----------------
1 file changed, 2 insertions(+), 19 deletions(-)
diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
index e5306c14e35..73473341443 100644
--- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
+++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
@@ -115,23 +115,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
...rest
} = props;
- const handleMouseOver = useCallback(
- (e: MouseEvent) => {
- if (onMouseOver) {
- onMouseOver(e);
- }
- },
- [onMouseOver]
- );
- const handleMouseOut = useCallback(
- (e: MouseEvent) => {
- if (onMouseOut) {
- onMouseOut(e);
- }
- },
- [onMouseOut]
- );
-
const openInNewTab = useCallback(
(e: MouseEvent) => {
if (!imageDTO) {
@@ -151,8 +134,8 @@ const IAIDndImage = (props: IAIDndImageProps) => {
return (
Date: Fri, 11 Oct 2024 20:10:54 +1000
Subject: [PATCH 04/11] perf(ui): remove another extraneous useCallback
---
.../ImageContextMenu/ImageContextMenu.tsx | 29 ++++++++++---------
1 file changed, 15 insertions(+), 14 deletions(-)
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
index a1aa5649b28..44c5c64bb05 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
@@ -34,17 +34,6 @@ export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: R
const longPressTimeoutRef = useRef(0);
const animationTimeoutRef = useRef(0);
- const activate = useCallback(
- (x: number, y: number) => {
- $imageContextMenuState.set({
- isOpen: true,
- position: { x, y },
- imageDTO: imageDTO ?? null,
- });
- },
- [imageDTO]
- );
-
const onContextMenu = useCallback(
(e: MouseEvent | PointerEvent) => {
if (e.shiftKey) {
@@ -52,6 +41,10 @@ export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: R
return;
}
+ if (!imageDTO) {
+ return;
+ }
+
if (targetRef.current?.contains(e.target as Node) || e.target === targetRef.current) {
// clear pending delayed open
window.clearTimeout(animationTimeoutRef.current);
@@ -62,17 +55,25 @@ export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: R
onClose();
}
animationTimeoutRef.current = window.setTimeout(() => {
- activate(e.pageX, e.pageY);
+ $imageContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ imageDTO,
+ });
}, 100);
} else {
// else we can just open the menu at the current position
- activate(e.pageX, e.pageY);
+ $imageContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ imageDTO,
+ });
}
}
lastPositionRef.current = { x: e.pageX, y: e.pageY };
},
- [activate, targetRef]
+ [imageDTO, targetRef]
);
// Use a long press to open the context menu on touch devices
From 3c6515fef6fd2bb7b23579bfd10080c2a160b820 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 20:26:36 +1000
Subject: [PATCH 05/11] perf(ui): memoize gallery page buttons
---
.../components/ImageGrid/GalleryPagination.tsx | 14 +++++++++-----
.../gallery/components/ImageGrid/JumpTo.tsx | 10 ++++++----
2 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx
index 27776ab77d8..3ccb475209d 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx
@@ -1,11 +1,11 @@
import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library';
import { ELLIPSIS, useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
-import { useCallback } from 'react';
+import { memo, useCallback } from 'react';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
import { JumpTo } from './JumpTo';
-export const GalleryPagination = () => {
+export const GalleryPagination = memo(() => {
const { goPrev, goNext, isPrevEnabled, isNextEnabled, pageButtons, goToPage, currentPage, total } =
useGalleryPagination();
@@ -47,7 +47,9 @@ export const GalleryPagination = () => {
);
-};
+});
+
+GalleryPagination.displayName = 'GalleryPagination';
type PageButtonProps = {
page: number | typeof ELLIPSIS;
@@ -55,7 +57,7 @@ type PageButtonProps = {
goToPage: (page: number) => void;
};
-const PageButton = ({ page, currentPage, goToPage }: PageButtonProps) => {
+const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => {
if (page === ELLIPSIS) {
return (
);
-};
+});
+
+PageButton.displayName = 'PageButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx
index 72f4bba4e81..743a41bea81 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/JumpTo.tsx
@@ -11,11 +11,11 @@ import {
useDisclosure,
} from '@invoke-ai/ui-library';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
-export const JumpTo = () => {
+export const JumpTo = memo(() => {
const { t } = useTranslation();
const { goToPage, currentPage, pages } = useGalleryPagination();
const [newPage, setNewPage] = useState(currentPage);
@@ -64,7 +64,7 @@ export const JumpTo = () => {
}, [currentPage]);
return (
-
+
);
-};
+});
+
+JumpTo.displayName = 'JumpTo';
From dadb7b160b409152ac78ad00c9af44fd21c63d12 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 20:41:22 +1000
Subject: [PATCH 06/11] perf(ui): use single event for all image context menus
Image elements register their target ref in a map, which is used to look up the image that was clicked on. Substantial perf improvement.
---
.../ImageContextMenu/ImageContextMenu.tsx | 165 ++++++++++--------
1 file changed, 91 insertions(+), 74 deletions(-)
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
index 44c5c64bb05..c8fa5fa55e0 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
@@ -28,53 +28,100 @@ const $imageContextMenuState = map({
const onClose = () => {
$imageContextMenuState.setKey('isOpen', false);
};
+const elToImageMap = new Map();
+const getImageDTOFromMap = (target: Node) => {
+ const parent = elToImageMap.keys().find((el) => el.contains(target));
+ return parent ? elToImageMap.get(parent) : undefined;
+};
export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: RefObject) => {
+ useEffect(() => {
+ if (!targetRef.current || !imageDTO) {
+ return;
+ }
+ const el = targetRef.current;
+ elToImageMap.set(el, imageDTO);
+ return () => {
+ elToImageMap.delete(el);
+ };
+ }, [imageDTO, targetRef]);
+};
+
+export const ImageContextMenu = memo(() => {
+ useAssertSingleton('ImageContextMenu');
+ const state = useStore($imageContextMenuState);
+ useGlobalMenuClose(onClose);
+
+ return (
+
+
+
+
+ );
+});
+
+ImageContextMenu.displayName = 'ImageContextMenu';
+
+const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
+
+const ImageContextMenuEventLogical = memo(() => {
const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 });
const longPressTimeoutRef = useRef(0);
const animationTimeoutRef = useRef(0);
- const onContextMenu = useCallback(
- (e: MouseEvent | PointerEvent) => {
- if (e.shiftKey) {
- onClose();
- return;
- }
+ const onContextMenu = useCallback((e: MouseEvent | PointerEvent) => {
+ if (e.shiftKey) {
+ onClose();
+ return;
+ }
- if (!imageDTO) {
- return;
- }
+ const imageDTO = getImageDTOFromMap(e.target as Node);
+
+ if (!imageDTO) {
+ onClose();
+ return;
+ }
+ // clear pending delayed open
+ window.clearTimeout(animationTimeoutRef.current);
+ e.preventDefault();
- if (targetRef.current?.contains(e.target as Node) || e.target === targetRef.current) {
- // clear pending delayed open
- window.clearTimeout(animationTimeoutRef.current);
- e.preventDefault();
- if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) {
- // if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
- if ($imageContextMenuState.get().isOpen) {
- onClose();
- }
- animationTimeoutRef.current = window.setTimeout(() => {
- $imageContextMenuState.set({
- isOpen: true,
- position: { x: e.pageX, y: e.pageY },
- imageDTO,
- });
- }, 100);
- } else {
- // else we can just open the menu at the current position
- $imageContextMenuState.set({
- isOpen: true,
- position: { x: e.pageX, y: e.pageY },
- imageDTO,
- });
- }
+ if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) {
+ // if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
+ if ($imageContextMenuState.get().isOpen) {
+ onClose();
}
+ animationTimeoutRef.current = window.setTimeout(() => {
+ $imageContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ imageDTO,
+ });
+ }, 100);
+ } else {
+ // else we can just open the menu at the current position
+ $imageContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ imageDTO,
+ });
+ }
- lastPositionRef.current = { x: e.pageX, y: e.pageY };
- },
- [imageDTO, targetRef]
- );
+ lastPositionRef.current = { x: e.pageX, y: e.pageY };
+ }, []);
// Use a long press to open the context menu on touch devices
const onPointerDown = useCallback(
@@ -132,25 +179,21 @@ export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: R
}, []);
useEffect(() => {
- if (!targetRef.current) {
- return;
- }
-
const controller = new AbortController();
// Context menu events
window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal });
// Long press events
- targetRef.current.addEventListener('pointerdown', onPointerDown, { signal: controller.signal });
- targetRef.current.addEventListener('pointerup', onPointerUp, { signal: controller.signal });
- targetRef.current.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal });
- targetRef.current.addEventListener('pointermove', onPointerMove, { signal: controller.signal });
+ window.addEventListener('pointerdown', onPointerDown, { signal: controller.signal });
+ window.addEventListener('pointerup', onPointerUp, { signal: controller.signal });
+ window.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal });
+ window.addEventListener('pointermove', onPointerMove, { signal: controller.signal });
return () => {
controller.abort();
};
- }, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp, targetRef]);
+ }, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
useEffect(
() => () => {
@@ -159,37 +202,11 @@ export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: R
},
[]
);
-};
-
-export const ImageContextMenu = memo(() => {
- useAssertSingleton('ImageContextMenu');
- const state = useStore($imageContextMenuState);
- useGlobalMenuClose(onClose);
- return (
-
-
-
- );
+ return null;
});
-ImageContextMenu.displayName = 'ImageContextMenu';
-
-const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
+ImageContextMenuEventLogical.displayName = 'ImageContextMenuEventLogical';
const MenuContent = memo(() => {
const selectionCount = useAppSelector(selectSelectionCount);
From b6c1f432d538d778ef1ac2a937fafcd2b9538abd Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 20:48:56 +1000
Subject: [PATCH 07/11] perf(ui): properly memoize gallery image icon
components
---
.../components/ImageGrid/GalleryImage.tsx | 145 ++++++++++--------
1 file changed, 81 insertions(+), 64 deletions(-)
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
index 8a4ca0e35da..8f361313af0 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
@@ -67,8 +67,6 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO);
- const customStarUi = useStore($customStarUI);
-
const imageContainerRef = useScrollIntoView(isSelected, index, areMultiplesSelected);
const draggableData = useMemo(() => {
@@ -91,20 +89,6 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
}
}, [imageDTO, selectedBoardId, areMultiplesSelected]);
- const [starImages] = useStarImagesMutation();
- const [unstarImages] = useUnstarImagesMutation();
-
- const toggleStarredState = useCallback(() => {
- if (imageDTO) {
- if (imageDTO.starred) {
- unstarImages({ imageDTOs: [imageDTO] });
- }
- if (!imageDTO.starred) {
- starImages({ imageDTOs: [imageDTO] });
- }
- }
- }, [starImages, unstarImages, imageDTO]);
-
const [isHovered, setIsHovered] = useState(false);
const handleMouseOver = useCallback(() => {
@@ -121,25 +105,6 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
setIsHovered(false);
}, []);
- const starIcon = useMemo(() => {
- if (imageDTO.starred) {
- return customStarUi ? customStarUi.on.icon : ;
- }
- if (!imageDTO.starred && isHovered) {
- return customStarUi ? customStarUi.off.icon : ;
- }
- }, [imageDTO.starred, isHovered, customStarUi]);
-
- const starTooltip = useMemo(() => {
- if (imageDTO.starred) {
- return customStarUi ? customStarUi.off.text : 'Unstar';
- }
- if (!imageDTO.starred) {
- return customStarUi ? customStarUi.on.text : 'Star';
- }
- return '';
- }, [imageDTO.starred, customStarUi]);
-
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);
if (!imageDTO) {
@@ -173,31 +138,8 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
onMouseOut={handleMouseOut}
>
<>
- {(isHovered || alwaysShowImageSizeBadge) && (
- {`${imageDTO.width}x${imageDTO.height}`}
- )}
-
+ {(isHovered || alwaysShowImageSizeBadge) && }
+ {(isHovered || imageDTO.starred) && }
{isHovered && }
{isHovered && }
>
@@ -209,7 +151,7 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
GalleryImageContent.displayName = 'GalleryImageContent';
-const DeleteIcon = ({ imageDTO }: { imageDTO: ImageDTO }) => {
+const DeleteIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const shift = useShiftModifier();
const { t } = useTranslation();
const dispatch = useAppDispatch();
@@ -238,9 +180,11 @@ const DeleteIcon = ({ imageDTO }: { imageDTO: ImageDTO }) => {
insetInlineEnd={2}
/>
);
-};
+});
+
+DeleteIcon.displayName = 'DeleteIcon';
-const OpenInViewerIconButton = ({ imageDTO }: { imageDTO: ImageDTO }) => {
+const OpenInViewerIconButton = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const imageViewer = useImageViewer();
const { t } = useTranslation();
@@ -258,4 +202,77 @@ const OpenInViewerIconButton = ({ imageDTO }: { imageDTO: ImageDTO }) => {
insetInlineStart={2}
/>
);
-};
+});
+
+OpenInViewerIconButton.displayName = 'OpenInViewerIconButton';
+
+const StarIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
+ const customStarUi = useStore($customStarUI);
+ const [starImages] = useStarImagesMutation();
+ const [unstarImages] = useUnstarImagesMutation();
+
+ const toggleStarredState = useCallback(() => {
+ if (imageDTO) {
+ if (imageDTO.starred) {
+ unstarImages({ imageDTOs: [imageDTO] });
+ }
+ if (!imageDTO.starred) {
+ starImages({ imageDTOs: [imageDTO] });
+ }
+ }
+ }, [starImages, unstarImages, imageDTO]);
+
+ const starIcon = useMemo(() => {
+ if (imageDTO.starred) {
+ return customStarUi ? customStarUi.on.icon : ;
+ }
+ if (!imageDTO.starred) {
+ return customStarUi ? customStarUi.off.icon : ;
+ }
+ }, [imageDTO.starred, customStarUi]);
+
+ const starTooltip = useMemo(() => {
+ if (imageDTO.starred) {
+ return customStarUi ? customStarUi.off.text : 'Unstar';
+ }
+ if (!imageDTO.starred) {
+ return customStarUi ? customStarUi.on.text : 'Star';
+ }
+ return '';
+ }, [imageDTO.starred, customStarUi]);
+
+ return (
+
+ );
+});
+
+StarIcon.displayName = 'StarIcon';
+
+const SizeBadge = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
+ return (
+ {`${imageDTO.width}x${imageDTO.height}`}
+ );
+});
+
+SizeBadge.displayName = 'SizeBadge';
From 606c478be59c7accde300cc37da3895bc822ea1b Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 20:50:30 +1000
Subject: [PATCH 08/11] tidy(ui): remove extraneous prop extraction
---
invokeai/frontend/web/src/common/components/IAIDndImage.tsx | 4 ----
1 file changed, 4 deletions(-)
diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
index 73473341443..f621e4e2076 100644
--- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
+++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
@@ -109,8 +109,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
useThumbailFallback,
withHoverOverlay = false,
children,
- onMouseOver,
- onMouseOut,
dataTestId,
...rest
} = props;
@@ -134,8 +132,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
return (
Date: Fri, 11 Oct 2024 20:54:25 +1000
Subject: [PATCH 09/11] perf(ui): efficient gallery image hover state
---
.../components/ImageGrid/GalleryImage.tsx | 26 ++++++++++++-------
1 file changed, 17 insertions(+), 9 deletions(-)
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
index 8f361313af0..191d466d6e7 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
@@ -63,7 +63,6 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name),
[imageDTO.image_name]
);
- const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO);
@@ -120,6 +119,8 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
justifyContent="center"
alignItems="center"
aspectRatio="1/1"
+ onMouseOver={handleMouseOver}
+ onMouseOut={handleMouseOut}
>
{
isUploadDisabled={true}
thumbnail={true}
withHoverOverlay
- onMouseOver={handleMouseOver}
- onMouseOut={handleMouseOut}
>
- <>
- {(isHovered || alwaysShowImageSizeBadge) && }
- {(isHovered || imageDTO.starred) && }
- {isHovered && }
- {isHovered && }
- >
+
@@ -151,6 +145,20 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
GalleryImageContent.displayName = 'GalleryImageContent';
+const HoverIcons = memo(({ imageDTO, isHovered }: { imageDTO: ImageDTO; isHovered: boolean }) => {
+ const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
+
+ return (
+ <>
+ {(isHovered || alwaysShowImageSizeBadge) && }
+ {(isHovered || imageDTO.starred) && }
+ {isHovered && }
+ {isHovered && }
+ >
+ );
+});
+HoverIcons.displayName = 'HoverIcons';
+
const DeleteIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
const shift = useShiftModifier();
const { t } = useTranslation();
From 8d2d7b38d3f4fe128d539269f7041695834f9cb5 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 21:26:40 +1000
Subject: [PATCH 10/11] fix(ui): safari doesn't have `find` on iterators
---
.../components/ImageContextMenu/ImageContextMenu.tsx | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
index c8fa5fa55e0..2b18ca56fc3 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
@@ -29,11 +29,10 @@ const onClose = () => {
$imageContextMenuState.setKey('isOpen', false);
};
const elToImageMap = new Map();
-const getImageDTOFromMap = (target: Node) => {
- const parent = elToImageMap.keys().find((el) => el.contains(target));
- return parent ? elToImageMap.get(parent) : undefined;
+const getImageDTOFromMap = (target: Node): ImageDTO | undefined => {
+ const entry = Array.from(elToImageMap.entries()).find((entry) => entry[0].contains(target));
+ return entry?.[1];
};
-
export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: RefObject) => {
useEffect(() => {
if (!targetRef.current || !imageDTO) {
From 0269c2d6191ec1be961d75cbb05565809aaa5e42 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 11 Oct 2024 21:26:58 +1000
Subject: [PATCH 11/11] docs(ui): add comments to ImageContextMenu
---
.../ImageContextMenu/ImageContextMenu.tsx | 53 +++++++++++++++++--
1 file changed, 48 insertions(+), 5 deletions(-)
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
index 2b18ca56fc3..934868e7cc6 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx
@@ -11,28 +11,55 @@ import type { RefObject } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import type { ImageDTO } from 'services/api/types';
+/**
+ * The delay in milliseconds before the context menu opens on long press.
+ */
const LONGPRESS_DELAY_MS = 500;
+/**
+ * The threshold in pixels that the pointer must move before the long press is cancelled.
+ */
const LONGPRESS_MOVE_THRESHOLD_PX = 10;
-type ImageContextMenuState = {
+/**
+ * The singleton state of the context menu.
+ */
+const $imageContextMenuState = map<{
isOpen: boolean;
imageDTO: ImageDTO | null;
position: { x: number; y: number };
-};
-
-const $imageContextMenuState = map({
+}>({
isOpen: false,
imageDTO: null,
position: { x: -1, y: -1 },
});
+
+/**
+ * Convenience function to close the context menu.
+ */
const onClose = () => {
$imageContextMenuState.setKey('isOpen', false);
};
+
+/**
+ * Map of elements to image DTOs. This is used to determine which image DTO to show the context menu for, depending on
+ * the target of the context menu or long press event.
+ */
const elToImageMap = new Map();
+
+/**
+ * Given a target node, find the first registered parent element that contains the target node and return the imageDTO
+ * associated with it.
+ */
const getImageDTOFromMap = (target: Node): ImageDTO | undefined => {
const entry = Array.from(elToImageMap.entries()).find((entry) => entry[0].contains(target));
return entry?.[1];
};
+
+/**
+ * Register a context menu for an image DTO on a target element.
+ * @param imageDTO The image DTO to register the context menu for.
+ * @param targetRef The ref of the target element that should trigger the context menu.
+ */
export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: RefObject) => {
useEffect(() => {
if (!targetRef.current || !imageDTO) {
@@ -46,6 +73,9 @@ export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: R
}, [imageDTO, targetRef]);
};
+/**
+ * Singleton component that renders the context menu for images.
+ */
export const ImageContextMenu = memo(() => {
useAssertSingleton('ImageContextMenu');
const state = useStore($imageContextMenuState);
@@ -77,6 +107,10 @@ ImageContextMenu.displayName = 'ImageContextMenu';
const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
+/**
+ * A logical component that listens for context menu events and opens the context menu. It's separate from
+ * ImageContextMenu component to avoid re-rendering the whole context menu on every context menu event.
+ */
const ImageContextMenuEventLogical = memo(() => {
const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 });
const longPressTimeoutRef = useRef(0);
@@ -84,6 +118,7 @@ const ImageContextMenuEventLogical = memo(() => {
const onContextMenu = useCallback((e: MouseEvent | PointerEvent) => {
if (e.shiftKey) {
+ // This is a shift + right click event, which should open the native context menu
onClose();
return;
}
@@ -91,9 +126,11 @@ const ImageContextMenuEventLogical = memo(() => {
const imageDTO = getImageDTOFromMap(e.target as Node);
if (!imageDTO) {
+ // Can't find the image DTO, close the context menu
onClose();
return;
}
+
// clear pending delayed open
window.clearTimeout(animationTimeoutRef.current);
e.preventDefault();
@@ -104,6 +141,7 @@ const ImageContextMenuEventLogical = memo(() => {
onClose();
}
animationTimeoutRef.current = window.setTimeout(() => {
+ // Open the menu after the animation with the new state
$imageContextMenuState.set({
isOpen: true,
position: { x: e.pageX, y: e.pageY },
@@ -111,7 +149,7 @@ const ImageContextMenuEventLogical = memo(() => {
});
}, 100);
} else {
- // else we can just open the menu at the current position
+ // else we can just open the menu at the current position w/ new state
$imageContextMenuState.set({
isOpen: true,
position: { x: e.pageX, y: e.pageY },
@@ -119,6 +157,7 @@ const ImageContextMenuEventLogical = memo(() => {
});
}
+ // Always sync the last position
lastPositionRef.current = { x: e.pageX, y: e.pageY };
}, []);
@@ -148,6 +187,7 @@ const ImageContextMenuEventLogical = memo(() => {
return;
}
+ // If the pointer has moved more than the threshold, cancel the long press
const lastPosition = lastPositionRef.current;
const distanceFromLastPosition = Math.hypot(e.pageX - lastPosition.x, e.pageY - lastPosition.y);
@@ -196,6 +236,7 @@ const ImageContextMenuEventLogical = memo(() => {
useEffect(
() => () => {
+ // Clean up any timeouts when we unmount
window.clearTimeout(animationTimeoutRef.current);
window.clearTimeout(longPressTimeoutRef.current);
},
@@ -207,6 +248,8 @@ const ImageContextMenuEventLogical = memo(() => {
ImageContextMenuEventLogical.displayName = 'ImageContextMenuEventLogical';
+// The content of the context menu, which changes based on the selection count. Split out and memoized to avoid
+// re-rendering the whole context menu too often.
const MenuContent = memo(() => {
const selectionCount = useAppSelector(selectSelectionCount);
const state = useStore($imageContextMenuState);