Skip to content

Commit 7d93329

Browse files
psychedelicioushipsterusername
authored andcommitted
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.
1 parent 968fb65 commit 7d93329

File tree

11 files changed

+100
-121
lines changed

11 files changed

+100
-121
lines changed
Lines changed: 74 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,15 @@
11
/**
2-
* This is a copy-paste of https://github.com/lukasbach/chakra-ui-contextmenu with a small change.
3-
*
4-
* The reactflow background element somehow prevents the chakra `useOutsideClick()` hook from working.
5-
* With a menu open, clicking on the reactflow background element doesn't close the menu.
6-
*
7-
* Reactflow does provide an `onPaneClick` to handle clicks on the background element, but it is not
8-
* straightforward to programatically close the menu.
9-
*
10-
* As a (hopefully temporary) workaround, we will use a dirty hack:
11-
* - create `globalContextMenuCloseTrigger: number` in `ui` slice
12-
* - increment it in `onPaneClick` (and wherever else we want to close the menu)
13-
* - `useEffect()` to close the menu when `globalContextMenuCloseTrigger` changes
2+
* Adapted from https://github.com/lukasbach/chakra-ui-contextmenu
143
*/
154
import type {
165
ChakraProps,
176
MenuButtonProps,
187
MenuProps,
198
PortalProps,
209
} from '@chakra-ui/react';
21-
import { Portal, useEventListener } from '@chakra-ui/react';
10+
import { Portal, useDisclosure, useEventListener } from '@chakra-ui/react';
2211
import { InvMenu, InvMenuButton } from 'common/components/InvMenu/wrapper';
23-
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
12+
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
2413
import { typedMemo } from 'common/util/typedMemo';
2514
import { useCallback, useEffect, useRef, useState } from 'react';
2615

@@ -34,94 +23,89 @@ export interface InvContextMenuProps<T extends HTMLElement = HTMLDivElement> {
3423

3524
export const InvContextMenu = typedMemo(
3625
<T extends HTMLElement = HTMLElement>(props: InvContextMenuProps<T>) => {
37-
const [isOpen, setIsOpen] = useState(false);
38-
const [isRendered, setIsRendered] = useState(false);
39-
const [isDeferredOpen, setIsDeferredOpen] = useState(false);
40-
const [position, setPosition] = useState<[number, number]>([0, 0]);
26+
const { isOpen, onOpen, onClose } = useDisclosure();
27+
const [position, setPosition] = useState([-1, -1]);
4128
const targetRef = useRef<T>(null);
29+
const lastPositionRef = useRef([-1, -1]);
30+
const timeoutRef = useRef(0);
4231

43-
useEffect(() => {
44-
if (isOpen) {
45-
setTimeout(() => {
46-
setIsRendered(true);
47-
setTimeout(() => {
48-
setIsDeferredOpen(true);
49-
});
50-
});
51-
} else {
52-
setIsDeferredOpen(false);
53-
const timeout = setTimeout(() => {
54-
setIsRendered(isOpen);
55-
}, 1000);
56-
return () => clearTimeout(timeout);
57-
}
58-
}, [isOpen]);
32+
useGlobalMenuClose(onClose);
5933

60-
const onClose = useCallback(() => {
61-
setIsOpen(false);
62-
setIsDeferredOpen(false);
63-
setIsRendered(false);
64-
}, []);
65-
66-
// This is the change from the original chakra-ui-contextmenu
67-
// Close all menus when the globalContextMenuCloseTrigger changes
68-
useGlobalMenuCloseTrigger(onClose);
34+
const onContextMenu = useCallback(
35+
(e: MouseEvent) => {
36+
if (e.shiftKey) {
37+
onClose();
38+
return;
39+
}
40+
if (
41+
targetRef.current?.contains(e.target as HTMLElement) ||
42+
e.target === targetRef.current
43+
) {
44+
// clear pending delayed open
45+
window.clearTimeout(timeoutRef.current);
46+
e.preventDefault();
47+
if (
48+
lastPositionRef.current[0] !== e.pageX ||
49+
lastPositionRef.current[1] !== e.pageY
50+
) {
51+
// if the mouse moved, we need to close, wait for animation and reopen the menu at the new position
52+
onClose();
53+
timeoutRef.current = window.setTimeout(() => {
54+
onOpen();
55+
setPosition([e.pageX, e.pageY]);
56+
}, 100);
57+
} else {
58+
// else we can just open the menu at the current position
59+
onOpen();
60+
setPosition([e.pageX, e.pageY]);
61+
}
62+
}
63+
lastPositionRef.current = [e.pageX, e.pageY];
64+
},
65+
[onClose, onOpen]
66+
);
6967

70-
useEventListener('contextmenu', (e) => {
71-
if (
72-
targetRef.current?.contains(e.target as HTMLElement) ||
73-
e.target === targetRef.current
74-
) {
75-
e.preventDefault();
76-
setIsOpen(true);
77-
setPosition([e.pageX, e.pageY]);
78-
} else {
79-
setIsOpen(false);
80-
}
81-
});
68+
useEffect(
69+
() => () => {
70+
window.clearTimeout(timeoutRef.current);
71+
},
72+
[]
73+
);
8274

83-
const onCloseHandler = useCallback(() => {
84-
props.menuProps?.onClose?.();
85-
setIsOpen(false);
86-
}, [props.menuProps]);
75+
useEventListener('contextmenu', onContextMenu);
8776

8877
return (
8978
<>
9079
{props.children(targetRef)}
91-
{isRendered && (
92-
<Portal {...props.portalProps}>
93-
<InvMenu
94-
isLazy
95-
isOpen={isDeferredOpen}
96-
gutter={0}
97-
onClose={onCloseHandler}
98-
placement="auto-end"
99-
{...props.menuProps}
100-
>
101-
<InvMenuButton
102-
aria-hidden={true}
103-
w={1}
104-
h={1}
105-
position="absolute"
106-
left={position[0]}
107-
top={position[1]}
108-
cursor="default"
109-
bg="transparent"
110-
size="sm"
111-
_hover={_hover}
112-
{...props.menuButtonProps}
113-
/>
114-
{props.renderMenu()}
115-
</InvMenu>
116-
</Portal>
117-
)}
80+
<Portal {...props.portalProps}>
81+
<InvMenu
82+
isLazy
83+
isOpen={isOpen}
84+
gutter={0}
85+
placement="auto-end"
86+
onClose={onClose}
87+
{...props.menuProps}
88+
>
89+
<InvMenuButton
90+
aria-hidden={true}
91+
w={1}
92+
h={1}
93+
position="absolute"
94+
left={position[0]}
95+
top={position[1]}
96+
cursor="default"
97+
bg="transparent"
98+
size="sm"
99+
_hover={_hover}
100+
pointerEvents="none"
101+
{...props.menuButtonProps}
102+
/>
103+
{props.renderMenu()}
104+
</InvMenu>
105+
</Portal>
118106
</>
119107
);
120108
}
121109
);
122110

123111
const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
124-
125-
Object.assign(InvContextMenu, {
126-
displayName: 'InvContextMenu',
127-
});

invokeai/frontend/web/src/common/components/InvMenu/InvMenuList.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
MenuList as ChakraMenuList,
44
Portal,
55
} from '@chakra-ui/react';
6+
import { skipMouseEvent } from 'common/util/skipMouseEvent';
67
import { memo } from 'react';
78

89
import { menuListMotionProps } from './constants';
@@ -16,6 +17,7 @@ export const InvMenuList = memo(
1617
<ChakraMenuList
1718
ref={ref}
1819
motionProps={menuListMotionProps}
20+
onContextMenu={skipMouseEvent}
1921
{...props}
2022
/>
2123
</Portal>

invokeai/frontend/web/src/common/hooks/useGlobalMenuCloseTrigger.ts renamed to invokeai/frontend/web/src/common/hooks/useGlobalMenuClose.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const $onCloseCallbacks = atom<CB[]>([]);
1515
* This hook provides a way to close all menus by calling `onCloseGlobal()`. Menus that want to be closed
1616
* in this way should register themselves by passing a callback to `useGlobalMenuCloseTrigger()`.
1717
*/
18-
export const useGlobalMenuCloseTrigger = (onClose?: CB) => {
18+
export const useGlobalMenuClose = (onClose?: CB) => {
1919
useEffect(() => {
2020
if (!onClose) {
2121
return;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { MouseEvent } from 'react';
2+
3+
/**
4+
* Prevents the default behavior of the event.
5+
*/
6+
export const skipMouseEvent = (e: MouseEvent) => {
7+
e.preventDefault();
8+
};

invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
import type { BoardId } from 'features/gallery/store/types';
1313
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
1414
import { addToast } from 'features/system/store/systemSlice';
15-
import type { MouseEvent } from 'react';
1615
import { memo, useCallback, useMemo } from 'react';
1716
import { useTranslation } from 'react-i18next';
1817
import { FaDownload, FaPlus } from 'react-icons/fa';
@@ -90,13 +89,9 @@ const BoardContextMenu = ({
9089
}
9190
}, [t, board_id, bulkDownload, dispatch]);
9291

93-
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
94-
e.preventDefault();
95-
}, []);
96-
9792
const renderMenuFunc = useCallback(
9893
() => (
99-
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
94+
<InvMenuList visibility="visible">
10095
<InvMenuGroup title={boardName}>
10196
<InvMenuItem
10297
icon={<FaPlus />}
@@ -131,7 +126,6 @@ const BoardContextMenu = ({
131126
isBulkDownloadEnabled,
132127
isSelectedForAutoAdd,
133128
setBoardToDelete,
134-
skipEvent,
135129
t,
136130
]
137131
);

invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useAppSelector } from 'app/store/storeHooks';
22
import type { InvContextMenuProps } from 'common/components/InvContextMenu/InvContextMenu';
33
import { InvContextMenu } from 'common/components/InvContextMenu/InvContextMenu';
44
import { InvMenuList } from 'common/components/InvMenu/InvMenuList';
5-
import type { MouseEvent } from 'react';
65
import { memo, useCallback } from 'react';
76
import type { ImageDTO } from 'services/api/types';
87

@@ -17,29 +16,25 @@ type Props = {
1716
const ImageContextMenu = ({ imageDTO, children }: Props) => {
1817
const selectionCount = useAppSelector((s) => s.gallery.selection.length);
1918

20-
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
21-
e.preventDefault();
22-
}, []);
23-
2419
const renderMenuFunc = useCallback(() => {
2520
if (!imageDTO) {
2621
return null;
2722
}
2823

2924
if (selectionCount > 1) {
3025
return (
31-
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
26+
<InvMenuList visibility="visible">
3227
<MultipleSelectionMenuItems />
3328
</InvMenuList>
3429
);
3530
}
3631

3732
return (
38-
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
33+
<InvMenuList visibility="visible">
3934
<SingleSelectionMenuItems imageDTO={imageDTO} />
4035
</InvMenuList>
4136
);
42-
}, [imageDTO, selectionCount, skipEvent]);
37+
}, [imageDTO, selectionCount]);
4338

4439
return (
4540
<InvContextMenu renderMenu={renderMenuFunc}>{children}</InvContextMenu>

invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useToken } from '@chakra-ui/react';
22
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3-
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
3+
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
44
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
55
import { $mouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
66
import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher';
@@ -158,7 +158,7 @@ export const Flow = memo(() => {
158158
[dispatch]
159159
);
160160

161-
const { onCloseGlobal } = useGlobalMenuCloseTrigger();
161+
const { onCloseGlobal } = useGlobalMenuClose();
162162
const handlePaneClick = useCallback(() => {
163163
onCloseGlobal();
164164
}, [onCloseGlobal]);

invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FieldContextMenu.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
workflowExposedFieldAdded,
1414
workflowExposedFieldRemoved,
1515
} from 'features/nodes/store/workflowSlice';
16-
import type { MouseEvent, ReactNode } from 'react';
16+
import type { ReactNode } from 'react';
1717
import { memo, useCallback, useMemo } from 'react';
1818
import { useTranslation } from 'react-i18next';
1919
import { FaMinus, FaPlus } from 'react-icons/fa';
@@ -32,10 +32,6 @@ const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
3232
const input = useFieldInputKind(nodeId, fieldName);
3333
const { t } = useTranslation();
3434

35-
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
36-
e.preventDefault();
37-
}, []);
38-
3935
const selectIsExposed = useMemo(
4036
() =>
4137
createSelector(selectWorkflowSlice, (workflow) => {
@@ -101,15 +97,15 @@ const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
10197
const renderMenuFunc = useCallback(
10298
() =>
10399
!menuItems.length ? null : (
104-
<InvMenuList visibility="visible" onContextMenu={skipEvent}>
100+
<InvMenuList visibility="visible">
105101
<InvMenuGroup
106102
title={label || fieldTemplateTitle || t('nodes.unknownField')}
107103
>
108104
{menuItems}
109105
</InvMenuGroup>
110106
</InvMenuList>
111107
),
112-
[fieldTemplateTitle, label, menuItems, skipEvent, t]
108+
[fieldTemplateTitle, label, menuItems, t]
113109
);
114110

115111
return (

invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Box, useToken } from '@chakra-ui/react';
33
import { createSelector } from '@reduxjs/toolkit';
44
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
55
import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
6-
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
6+
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
77
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
88
import {
99
nodeExclusivelySelected,
@@ -50,7 +50,7 @@ const NodeWrapper = (props: NodeWrapperProps) => {
5050
const dispatch = useAppDispatch();
5151

5252
const opacity = useAppSelector((s) => s.nodes.nodeOpacity);
53-
const { onCloseGlobal } = useGlobalMenuCloseTrigger();
53+
const { onCloseGlobal } = useGlobalMenuClose();
5454

5555
const handleClick = useCallback(
5656
(e: MouseEvent<HTMLDivElement>) => {

invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
InvMenuButton,
88
InvMenuGroup,
99
} from 'common/components/InvMenu/wrapper';
10-
import { useGlobalMenuCloseTrigger } from 'common/hooks/useGlobalMenuCloseTrigger';
10+
import { useGlobalMenuClose } from 'common/hooks/useGlobalMenuClose';
1111
import HotkeysModal from 'features/system/components/HotkeysModal/HotkeysModal';
1212
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
1313
import { memo } from 'react';
@@ -23,7 +23,7 @@ import SettingsModal from './SettingsModal';
2323
const SettingsMenu = () => {
2424
const { t } = useTranslation();
2525
const { isOpen, onOpen, onClose } = useDisclosure();
26-
useGlobalMenuCloseTrigger(onClose);
26+
useGlobalMenuClose(onClose);
2727

2828
const isBugLinkEnabled = useFeatureStatus('bugLink').isFeatureEnabled;
2929
const isDiscordLinkEnabled = useFeatureStatus('discordLink').isFeatureEnabled;

0 commit comments

Comments
 (0)