mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
fix(ui): fix context menu on workflow editor
There is a tricky mouse event interaction between chakra's `useOutsideClick()` hook (used by chakra `<Menu />`) and reactflow. The hook doesn't work when you click the main reactflow area. To get around this, I've used a dirty hack, copy-pasting the simple context menu component we use, and extending it slightly to respond to a global `contextMenusClosed` redux action.
This commit is contained in:
126
invokeai/frontend/web/src/common/components/IAIContextMenu.tsx
Normal file
126
invokeai/frontend/web/src/common/components/IAIContextMenu.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 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`
|
||||||
|
* - `useEffect()` to close the menu when `globalContextMenuCloseTrigger` changes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuButtonProps,
|
||||||
|
MenuProps,
|
||||||
|
Portal,
|
||||||
|
PortalProps,
|
||||||
|
useEventListener,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
MutableRefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export interface IAIContextMenuProps<T extends HTMLElement> {
|
||||||
|
renderMenu: () => JSX.Element | null;
|
||||||
|
children: (ref: MutableRefObject<T | null>) => JSX.Element | null;
|
||||||
|
menuProps?: Omit<MenuProps, 'children'> & { children?: React.ReactNode };
|
||||||
|
portalProps?: Omit<PortalProps, 'children'> & { children?: React.ReactNode };
|
||||||
|
menuButtonProps?: MenuButtonProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IAIContextMenu<T extends HTMLElement = HTMLElement>(
|
||||||
|
props: IAIContextMenuProps<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 targetRef = useRef<T>(null);
|
||||||
|
|
||||||
|
const globalContextMenuCloseTrigger = useAppSelector(
|
||||||
|
(state) => state.ui.globalContextMenuCloseTrigger
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsRendered(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsDeferredOpen(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsDeferredOpen(false);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsRendered(isOpen);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsDeferredOpen(false);
|
||||||
|
setIsRendered(false);
|
||||||
|
}, [globalContextMenuCloseTrigger]);
|
||||||
|
|
||||||
|
useEventListener('contextmenu', (e) => {
|
||||||
|
if (
|
||||||
|
targetRef.current?.contains(e.target as HTMLElement) ||
|
||||||
|
e.target === targetRef.current
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
setPosition([e.pageX, e.pageY]);
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCloseHandler = useCallback(() => {
|
||||||
|
props.menuProps?.onClose?.();
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [props.menuProps]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.children(targetRef)}
|
||||||
|
{isRendered && (
|
||||||
|
<Portal {...props.portalProps}>
|
||||||
|
<Menu
|
||||||
|
isOpen={isDeferredOpen}
|
||||||
|
gutter={0}
|
||||||
|
{...props.menuProps}
|
||||||
|
onClose={onCloseHandler}
|
||||||
|
>
|
||||||
|
<MenuButton
|
||||||
|
aria-hidden={true}
|
||||||
|
w={1}
|
||||||
|
h={1}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: position[0],
|
||||||
|
top: position[1],
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
{...props.menuButtonProps}
|
||||||
|
/>
|
||||||
|
{props.renderMenu()}
|
||||||
|
</Menu>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -16,6 +16,7 @@ import ImageContextMenu from 'features/gallery/components/ImageContextMenu/Image
|
|||||||
import {
|
import {
|
||||||
MouseEvent,
|
MouseEvent,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
SyntheticEvent,
|
SyntheticEvent,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -32,6 +33,17 @@ import {
|
|||||||
TypesafeDroppableData,
|
TypesafeDroppableData,
|
||||||
} from 'features/dnd/types';
|
} from 'features/dnd/types';
|
||||||
|
|
||||||
|
const defaultUploadElement = (
|
||||||
|
<Icon
|
||||||
|
as={FaUpload}
|
||||||
|
sx={{
|
||||||
|
boxSize: 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultNoContentFallback = <IAINoContentFallback icon={FaImage} />;
|
||||||
|
|
||||||
type IAIDndImageProps = FlexProps & {
|
type IAIDndImageProps = FlexProps & {
|
||||||
imageDTO: ImageDTO | undefined;
|
imageDTO: ImageDTO | undefined;
|
||||||
onError?: (event: SyntheticEvent<HTMLImageElement>) => void;
|
onError?: (event: SyntheticEvent<HTMLImageElement>) => void;
|
||||||
@ -47,13 +59,14 @@ type IAIDndImageProps = FlexProps & {
|
|||||||
fitContainer?: boolean;
|
fitContainer?: boolean;
|
||||||
droppableData?: TypesafeDroppableData;
|
droppableData?: TypesafeDroppableData;
|
||||||
draggableData?: TypesafeDraggableData;
|
draggableData?: TypesafeDraggableData;
|
||||||
dropLabel?: string;
|
dropLabel?: ReactNode;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
thumbnail?: boolean;
|
thumbnail?: boolean;
|
||||||
noContentFallback?: ReactElement;
|
noContentFallback?: ReactElement;
|
||||||
useThumbailFallback?: boolean;
|
useThumbailFallback?: boolean;
|
||||||
withHoverOverlay?: boolean;
|
withHoverOverlay?: boolean;
|
||||||
children?: JSX.Element;
|
children?: JSX.Element;
|
||||||
|
uploadElement?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IAIDndImage = (props: IAIDndImageProps) => {
|
const IAIDndImage = (props: IAIDndImageProps) => {
|
||||||
@ -74,7 +87,8 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
|||||||
dropLabel,
|
dropLabel,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
thumbnail = false,
|
thumbnail = false,
|
||||||
noContentFallback = <IAINoContentFallback icon={FaImage} />,
|
noContentFallback = defaultNoContentFallback,
|
||||||
|
uploadElement = defaultUploadElement,
|
||||||
useThumbailFallback,
|
useThumbailFallback,
|
||||||
withHoverOverlay = false,
|
withHoverOverlay = false,
|
||||||
children,
|
children,
|
||||||
@ -193,12 +207,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
|||||||
{...getUploadButtonProps()}
|
{...getUploadButtonProps()}
|
||||||
>
|
>
|
||||||
<input {...getUploadInputProps()} />
|
<input {...getUploadInputProps()} />
|
||||||
<Icon
|
{uploadElement}
|
||||||
as={FaUpload}
|
|
||||||
sx={{
|
|
||||||
boxSize: 16,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -210,6 +219,7 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{children}
|
||||||
{!isDropDisabled && (
|
{!isDropDisabled && (
|
||||||
<IAIDroppable
|
<IAIDroppable
|
||||||
data={droppableData}
|
data={droppableData}
|
||||||
@ -217,7 +227,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
|
|||||||
dropLabel={dropLabel}
|
dropLabel={dropLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{children}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</ImageContextMenu>
|
</ImageContextMenu>
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { MenuList } from '@chakra-ui/react';
|
import { MenuList } from '@chakra-ui/react';
|
||||||
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
|
import {
|
||||||
|
IAIContextMenu,
|
||||||
|
IAIContextMenuProps,
|
||||||
|
} from 'common/components/IAIContextMenu';
|
||||||
import { MouseEvent, memo, useCallback } from 'react';
|
import { MouseEvent, memo, useCallback } from 'react';
|
||||||
import { ImageDTO } from 'services/api/types';
|
import { ImageDTO } from 'services/api/types';
|
||||||
import { menuListMotionProps } from 'theme/components/menu';
|
import { menuListMotionProps } from 'theme/components/menu';
|
||||||
@ -12,7 +15,7 @@ import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageDTO: ImageDTO | undefined;
|
imageDTO: ImageDTO | undefined;
|
||||||
children: ContextMenuProps<HTMLDivElement>['children'];
|
children: IAIContextMenuProps<HTMLDivElement>['children'];
|
||||||
};
|
};
|
||||||
|
|
||||||
const selector = createSelector(
|
const selector = createSelector(
|
||||||
@ -33,7 +36,7 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu<HTMLDivElement>
|
<IAIContextMenu<HTMLDivElement>
|
||||||
menuProps={{ size: 'sm', isLazy: true }}
|
menuProps={{ size: 'sm', isLazy: true }}
|
||||||
menuButtonProps={{
|
menuButtonProps={{
|
||||||
bg: 'transparent',
|
bg: 'transparent',
|
||||||
@ -68,7 +71,7 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenu>
|
</IAIContextMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useToken } from '@chakra-ui/react';
|
import { useToken } from '@chakra-ui/react';
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { contextMenusClosed } from 'features/ui/store/uiSlice';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Background,
|
Background,
|
||||||
@ -114,6 +115,10 @@ export const Flow = () => {
|
|||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePaneClick = useCallback(() => {
|
||||||
|
dispatch(contextMenusClosed());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
defaultViewport={viewport}
|
defaultViewport={viewport}
|
||||||
@ -138,6 +143,7 @@ export const Flow = () => {
|
|||||||
connectionRadius={30}
|
connectionRadius={30}
|
||||||
proOptions={proOptions}
|
proOptions={proOptions}
|
||||||
style={{ borderRadius }}
|
style={{ borderRadius }}
|
||||||
|
onPaneClick={handlePaneClick}
|
||||||
>
|
>
|
||||||
<TopLeftPanel />
|
<TopLeftPanel />
|
||||||
<TopCenterPanel />
|
<TopCenterPanel />
|
||||||
|
@ -3,4 +3,7 @@ import { UIState } from './uiTypes';
|
|||||||
/**
|
/**
|
||||||
* UI slice persist denylist
|
* UI slice persist denylist
|
||||||
*/
|
*/
|
||||||
export const uiPersistDenylist: (keyof UIState)[] = ['shouldShowImageDetails'];
|
export const uiPersistDenylist: (keyof UIState)[] = [
|
||||||
|
'shouldShowImageDetails',
|
||||||
|
'globalContextMenuCloseTrigger',
|
||||||
|
];
|
||||||
|
@ -20,6 +20,7 @@ export const initialUIState: UIState = {
|
|||||||
shouldShowProgressInViewer: true,
|
shouldShowProgressInViewer: true,
|
||||||
shouldShowEmbeddingPicker: false,
|
shouldShowEmbeddingPicker: false,
|
||||||
favoriteSchedulers: [],
|
favoriteSchedulers: [],
|
||||||
|
globalContextMenuCloseTrigger: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uiSlice = createSlice({
|
export const uiSlice = createSlice({
|
||||||
@ -96,6 +97,9 @@ export const uiSlice = createSlice({
|
|||||||
toggleEmbeddingPicker: (state) => {
|
toggleEmbeddingPicker: (state) => {
|
||||||
state.shouldShowEmbeddingPicker = !state.shouldShowEmbeddingPicker;
|
state.shouldShowEmbeddingPicker = !state.shouldShowEmbeddingPicker;
|
||||||
},
|
},
|
||||||
|
contextMenusClosed: (state) => {
|
||||||
|
state.globalContextMenuCloseTrigger += 1;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers(builder) {
|
extraReducers(builder) {
|
||||||
builder.addCase(initialImageChanged, (state) => {
|
builder.addCase(initialImageChanged, (state) => {
|
||||||
@ -122,6 +126,7 @@ export const {
|
|||||||
setShouldShowProgressInViewer,
|
setShouldShowProgressInViewer,
|
||||||
favoriteSchedulersChanged,
|
favoriteSchedulersChanged,
|
||||||
toggleEmbeddingPicker,
|
toggleEmbeddingPicker,
|
||||||
|
contextMenusClosed,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
|
|
||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
|
@ -26,4 +26,5 @@ export interface UIState {
|
|||||||
shouldShowProgressInViewer: boolean;
|
shouldShowProgressInViewer: boolean;
|
||||||
shouldShowEmbeddingPicker: boolean;
|
shouldShowEmbeddingPicker: boolean;
|
||||||
favoriteSchedulers: SchedulerParam[];
|
favoriteSchedulers: SchedulerParam[];
|
||||||
|
globalContextMenuCloseTrigger: number;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user