mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): de-jank context menu
There was a lot of convoluted, janky logic related to trying to not mount the context menu's portal until its needed. This was in the library where the component was originally copied from. I've removed that and resolved the jank, at the cost of there being an extra portal for each instance of the context menu. Don't think this is going to be an issue. If it is, the whole context menu could be refactored to be a singleton.
This commit is contained in:
parent
968fb655a4
commit
7d93329401
@ -1,16 +1,5 @@
|
||||
/**
|
||||
* This is a copy-paste of https://github.com/lukasbach/chakra-ui-contextmenu with a small change.
|
||||
*
|
||||
* The reactflow background element somehow prevents the chakra `useOutsideClick()` hook from working.
|
||||
* With a menu open, clicking on the reactflow background element doesn't close the menu.
|
||||
*
|
||||
* Reactflow does provide an `onPaneClick` to handle clicks on the background element, but it is not
|
||||
* straightforward to programatically close the menu.
|
||||
*
|
||||
* As a (hopefully temporary) workaround, we will use a dirty hack:
|
||||
* - create `globalContextMenuCloseTrigger: number` in `ui` slice
|
||||
* - increment it in `onPaneClick` (and wherever else we want to close the menu)
|
||||
* - `useEffect()` to close the menu when `globalContextMenuCloseTrigger` changes
|
||||
* Adapted from https://github.com/lukasbach/chakra-ui-contextmenu
|
||||
*/
|
||||
import type {
|
||||
ChakraProps,
|
||||
@ -18,9 +7,9 @@ import type {
|
||||
MenuProps,
|
||||
PortalProps,
|
||||
} from '@chakra-ui/react';
|
||||
import { Portal, useEventListener } from '@chakra-ui/react';
|
||||
import { Portal, useDisclosure, useEventListener } from '@chakra-ui/react';
|
||||
import { InvMenu, InvMenuButton } from 'common/components/InvMenu/wrapper';
|
||||
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
|
||||
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
|
||||
import { typedMemo } from 'common/util/typedMemo';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
@ -34,68 +23,67 @@ export interface InvContextMenuProps<T extends HTMLElement = HTMLDivElement> {
|
||||
|
||||
export const InvContextMenu = typedMemo(
|
||||
<T extends HTMLElement = HTMLElement>(props: InvContextMenuProps<T>) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [isDeferredOpen, setIsDeferredOpen] = useState(false);
|
||||
const [position, setPosition] = useState<[number, number]>([0, 0]);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [position, setPosition] = useState([-1, -1]);
|
||||
const targetRef = useRef<T>(null);
|
||||
const lastPositionRef = useRef([-1, -1]);
|
||||
const timeoutRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
setIsRendered(true);
|
||||
setTimeout(() => {
|
||||
setIsDeferredOpen(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsDeferredOpen(false);
|
||||
const timeout = setTimeout(() => {
|
||||
setIsRendered(isOpen);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
useGlobalMenuClose(onClose);
|
||||
|
||||
const onContextMenu = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (e.shiftKey) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setIsDeferredOpen(false);
|
||||
setIsRendered(false);
|
||||
}, []);
|
||||
|
||||
// This is the change from the original chakra-ui-contextmenu
|
||||
// Close all menus when the globalContextMenuCloseTrigger changes
|
||||
useGlobalMenuCloseTrigger(onClose);
|
||||
|
||||
useEventListener('contextmenu', (e) => {
|
||||
if (
|
||||
targetRef.current?.contains(e.target as HTMLElement) ||
|
||||
e.target === targetRef.current
|
||||
) {
|
||||
// clear pending delayed open
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
if (
|
||||
lastPositionRef.current[0] !== e.pageX ||
|
||||
lastPositionRef.current[1] !== e.pageY
|
||||
) {
|
||||
// if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
|
||||
onClose();
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onOpen();
|
||||
setPosition([e.pageX, e.pageY]);
|
||||
}, 100);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
// else we can just open the menu at the current position
|
||||
onOpen();
|
||||
setPosition([e.pageX, e.pageY]);
|
||||
}
|
||||
});
|
||||
}
|
||||
lastPositionRef.current = [e.pageX, e.pageY];
|
||||
},
|
||||
[onClose, onOpen]
|
||||
);
|
||||
|
||||
const onCloseHandler = useCallback(() => {
|
||||
props.menuProps?.onClose?.();
|
||||
setIsOpen(false);
|
||||
}, [props.menuProps]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEventListener('contextmenu', onContextMenu);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.children(targetRef)}
|
||||
{isRendered && (
|
||||
<Portal {...props.portalProps}>
|
||||
<InvMenu
|
||||
isLazy
|
||||
isOpen={isDeferredOpen}
|
||||
isOpen={isOpen}
|
||||
gutter={0}
|
||||
onClose={onCloseHandler}
|
||||
placement="auto-end"
|
||||
onClose={onClose}
|
||||
{...props.menuProps}
|
||||
>
|
||||
<InvMenuButton
|
||||
@ -109,19 +97,15 @@ export const InvContextMenu = typedMemo(
|
||||
bg="transparent"
|
||||
size="sm"
|
||||
_hover={_hover}
|
||||
pointerEvents="none"
|
||||
{...props.menuButtonProps}
|
||||
/>
|
||||
{props.renderMenu()}
|
||||
</InvMenu>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
|
||||
|
||||
Object.assign(InvContextMenu, {
|
||||
displayName: 'InvContextMenu',
|
||||
});
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
MenuList as ChakraMenuList,
|
||||
Portal,
|
||||
} from '@chakra-ui/react';
|
||||
import { skipMouseEvent } from 'common/util/skipMouseEvent';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { menuListMotionProps } from './constants';
|
||||
@ -16,6 +17,7 @@ export const InvMenuList = memo(
|
||||
<ChakraMenuList
|
||||
ref={ref}
|
||||
motionProps={menuListMotionProps}
|
||||
onContextMenu={skipMouseEvent}
|
||||
{...props}
|
||||
/>
|
||||
</Portal>
|
||||
|
@ -15,7 +15,7 @@ const $onCloseCallbacks = atom<CB[]>([]);
|
||||
* This hook provides a way to close all menus by calling `onCloseGlobal()`. Menus that want to be closed
|
||||
* in this way should register themselves by passing a callback to `useGlobalMenuCloseTrigger()`.
|
||||
*/
|
||||
export const useGlobalMenuCloseTrigger = (onClose?: CB) => {
|
||||
export const useGlobalMenuClose = (onClose?: CB) => {
|
||||
useEffect(() => {
|
||||
if (!onClose) {
|
||||
return;
|
8
invokeai/frontend/web/src/common/util/skipMouseEvent.ts
Normal file
8
invokeai/frontend/web/src/common/util/skipMouseEvent.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Prevents the default behavior of the event.
|
||||
*/
|
||||
export const skipMouseEvent = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
@ -12,7 +12,6 @@ import {
|
||||
import type { BoardId } from 'features/gallery/store/types';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { addToast } from 'features/system/store/systemSlice';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaDownload, FaPlus } from 'react-icons/fa';
|
||||
@ -90,13 +89,9 @@ const BoardContextMenu = ({
|
||||
}
|
||||
}, [t, board_id, bulkDownload, dispatch]);
|
||||
|
||||
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const renderMenuFunc = useCallback(
|
||||
() => (
|
||||
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
|
||||
<InvMenuList visibility="visible">
|
||||
<InvMenuGroup title={boardName}>
|
||||
<InvMenuItem
|
||||
icon={<FaPlus />}
|
||||
@ -131,7 +126,6 @@ const BoardContextMenu = ({
|
||||
isBulkDownloadEnabled,
|
||||
isSelectedForAutoAdd,
|
||||
setBoardToDelete,
|
||||
skipEvent,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
@ -2,7 +2,6 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import type { InvContextMenuProps } from 'common/components/InvContextMenu/InvContextMenu';
|
||||
import { InvContextMenu } from 'common/components/InvContextMenu/InvContextMenu';
|
||||
import { InvMenuList } from 'common/components/InvMenu/InvMenuList';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
@ -17,10 +16,6 @@ type Props = {
|
||||
const ImageContextMenu = ({ imageDTO, children }: Props) => {
|
||||
const selectionCount = useAppSelector((s) => s.gallery.selection.length);
|
||||
|
||||
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const renderMenuFunc = useCallback(() => {
|
||||
if (!imageDTO) {
|
||||
return null;
|
||||
@ -28,18 +23,18 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
|
||||
|
||||
if (selectionCount > 1) {
|
||||
return (
|
||||
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
|
||||
<InvMenuList visibility="visible">
|
||||
<MultipleSelectionMenuItems />
|
||||
</InvMenuList>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
|
||||
<InvMenuList visibility="visible">
|
||||
<SingleSelectionMenuItems imageDTO={imageDTO} />
|
||||
</InvMenuList>
|
||||
);
|
||||
}, [imageDTO, selectionCount, skipEvent]);
|
||||
}, [imageDTO, selectionCount]);
|
||||
|
||||
return (
|
||||
<InvContextMenu renderMenu={renderMenuFunc}>{children}</InvContextMenu>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useToken } from '@chakra-ui/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
|
||||
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
|
||||
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
|
||||
import { $mouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher';
|
||||
@ -158,7 +158,7 @@ export const Flow = memo(() => {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const { onCloseGlobal } = useGlobalMenuCloseTrigger();
|
||||
const { onCloseGlobal } = useGlobalMenuClose();
|
||||
const handlePaneClick = useCallback(() => {
|
||||
onCloseGlobal();
|
||||
}, [onCloseGlobal]);
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
workflowExposedFieldAdded,
|
||||
workflowExposedFieldRemoved,
|
||||
} from 'features/nodes/store/workflowSlice';
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaMinus, FaPlus } from 'react-icons/fa';
|
||||
@ -32,10 +32,6 @@ const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
|
||||
const input = useFieldInputKind(nodeId, fieldName);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const selectIsExposed = useMemo(
|
||||
() =>
|
||||
createSelector(selectWorkflowSlice, (workflow) => {
|
||||
@ -101,7 +97,7 @@ const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
|
||||
const renderMenuFunc = useCallback(
|
||||
() =>
|
||||
!menuItems.length ? null : (
|
||||
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
|
||||
<InvMenuList visibility="visible">
|
||||
<InvMenuGroup
|
||||
title={label || fieldTemplateTitle || t('nodes.unknownField')}
|
||||
>
|
||||
@ -109,7 +105,7 @@ const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
|
||||
</InvMenuGroup>
|
||||
</InvMenuList>
|
||||
),
|
||||
[fieldTemplateTitle, label, menuItems, skipEvent, t]
|
||||
[fieldTemplateTitle, label, menuItems, t]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -3,7 +3,7 @@ import { Box, useToken } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
|
||||
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
|
||||
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
|
||||
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import {
|
||||
nodeExclusivelySelected,
|
||||
@ -50,7 +50,7 @@ const NodeWrapper = (props: NodeWrapperProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const opacity = useAppSelector((s) => s.nodes.nodeOpacity);
|
||||
const { onCloseGlobal } = useGlobalMenuCloseTrigger();
|
||||
const { onCloseGlobal } = useGlobalMenuClose();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
InvMenuButton,
|
||||
InvMenuGroup,
|
||||
} from 'common/components/InvMenu/wrapper';
|
||||
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
|
||||
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
|
||||
import HotkeysModal from 'features/system/components/HotkeysModal/HotkeysModal';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { memo } from 'react';
|
||||
@ -23,7 +23,7 @@ import SettingsModal from './SettingsModal';
|
||||
const SettingsMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
useGlobalMenuCloseTrigger(onClose);
|
||||
useGlobalMenuClose(onClose);
|
||||
|
||||
const isBugLinkEnabled = useFeatureStatus('bugLink').isFeatureEnabled;
|
||||
const isDiscordLinkEnabled = useFeatureStatus('discordLink').isFeatureEnabled;
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
InvMenuButton,
|
||||
InvMenuDivider,
|
||||
} from 'common/components/InvMenu/wrapper';
|
||||
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
|
||||
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem';
|
||||
import NewWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
|
||||
@ -21,7 +21,7 @@ import { PiDotsThreeOutlineFill } from 'react-icons/pi';
|
||||
const WorkflowLibraryMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
useGlobalMenuCloseTrigger(onClose);
|
||||
useGlobalMenuClose(onClose);
|
||||
|
||||
const isWorkflowLibraryEnabled =
|
||||
useFeatureStatus('workflowLibrary').isFeatureEnabled;
|
||||
|
Loading…
Reference in New Issue
Block a user