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 ( - +