NEWONE1 / invokeai /frontend /web /src /features /gallery /components /ImageContextMenu /ImageContextMenu.tsx
| 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 { map } from 'nanostores'; | |
| 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; | |
| /** | |
| * The singleton state of the context menu. | |
| */ | |
| const $imageContextMenuState = map<{ | |
| isOpen: boolean; | |
| imageDTO: ImageDTO | null; | |
| position: { x: number; y: number }; | |
| }>({ | |
| 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<HTMLDivElement, ImageDTO>(); | |
| /** | |
| * 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: HTMLDivElement | null) => { | |
| useEffect(() => { | |
| if (!targetRef || !imageDTO) { | |
| return; | |
| } | |
| const el = targetRef; | |
| elToImageMap.set(el, imageDTO); | |
| return () => { | |
| elToImageMap.delete(el); | |
| }; | |
| }, [imageDTO, targetRef]); | |
| }; | |
| /** | |
| * Singleton component that renders the context menu for images. | |
| */ | |
| export const ImageContextMenu = memo(() => { | |
| useAssertSingleton('ImageContextMenu'); | |
| const state = useStore($imageContextMenuState); | |
| useGlobalMenuClose(onClose); | |
| return ( | |
| <Portal> | |
| <Menu isOpen={state.isOpen} gutter={0} placement="auto-end" onClose={onClose}> | |
| <MenuButton | |
| aria-hidden={true} | |
| w={1} | |
| h={1} | |
| position="absolute" | |
| left={state.position.x} | |
| top={state.position.y} | |
| cursor="default" | |
| bg="transparent" | |
| _hover={_hover} | |
| pointerEvents="none" | |
| /> | |
| <MenuContent /> | |
| </Menu> | |
| <ImageContextMenuEventLogical /> | |
| </Portal> | |
| ); | |
| }); | |
| 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); | |
| const animationTimeoutRef = useRef(0); | |
| 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; | |
| } | |
| 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(); | |
| 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(() => { | |
| // Open the menu after the animation with the new state | |
| $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 w/ new state | |
| $imageContextMenuState.set({ | |
| isOpen: true, | |
| position: { x: e.pageX, y: e.pageY }, | |
| imageDTO, | |
| }); | |
| } | |
| // Always sync the last position | |
| lastPositionRef.current = { x: e.pageX, y: e.pageY }; | |
| }, []); | |
| // 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); | |
| 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 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); | |
| 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(() => { | |
| const controller = new AbortController(); | |
| // Context menu events | |
| window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal }); | |
| // Long press events | |
| 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]); | |
| useEffect( | |
| () => () => { | |
| // Clean up any timeouts when we unmount | |
| window.clearTimeout(animationTimeoutRef.current); | |
| window.clearTimeout(longPressTimeoutRef.current); | |
| }, | |
| [] | |
| ); | |
| return null; | |
| }); | |
| 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); | |
| if (!state.imageDTO) { | |
| return null; | |
| } | |
| if (selectionCount > 1) { | |
| return ( | |
| <MenuList visibility="visible"> | |
| <MultipleSelectionMenuItems /> | |
| </MenuList> | |
| ); | |
| } | |
| return ( | |
| <MenuList visibility="visible"> | |
| <SingleSelectionMenuItems imageDTO={state.imageDTO} /> | |
| </MenuList> | |
| ); | |
| }); | |
| MenuContent.displayName = 'MenuContent'; | |