Files
2024-02-15 17:01:50 +00:00

200 lines
5.2 KiB
TypeScript

import {useCallback, useEffect, useRef, useState} from 'react';
import {clamp} from 'sentry/utils/profiling/colors/utils';
import {Rect} from 'sentry/utils/profiling/speedscope';
import {useKeyboardNavigation} from './useKeyboardNavigation';
export function computeBestContextMenuPosition(
mouse: Rect,
container: Rect,
target: Rect
) {
const maxY = Math.floor(container.height - target.height);
const minY = container.top;
const minX = container.left;
const maxX = Math.floor(container.right - target.width);
// We add a tiny offset so that the menu is not directly where the user places their cursor.
const OFFSET = 6;
return {
left: clamp(mouse.x + OFFSET, minX, maxX),
top: clamp(mouse.y + OFFSET, minY, maxY),
};
}
interface UseContextMenuOptions {
container: HTMLElement | null;
}
export function useContextMenu({container}: UseContextMenuOptions) {
const [open, setOpen] = useState<boolean>(false);
const [menuCoordinates, setMenuCoordinates] = useState<Rect | null>(null);
const [contextMenuCoordinates, setContextMenuCoordinates] = useState<Rect | null>(null);
const [containerCoordinates, setContainerCoordinates] = useState<Rect | null>(null);
const itemProps = useKeyboardNavigation();
const subMenuRef = useRef<HTMLElement | null>(null);
// We wrap the setOpen function in a useEffect so that we also clear the keyboard
// tabIndex when a menu is closed. This prevents tabIndex from being persisted between render
const wrapSetOpen = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
itemProps.setTabIndex(null);
}
setOpen(newOpen);
},
[itemProps]
);
const getMenuProps = useCallback(() => {
const menuProps = itemProps.getMenuProps();
return {
...menuProps,
onKeyDown: (evt: React.KeyboardEvent) => {
if (evt.key === 'Escape') {
wrapSetOpen(false);
}
menuProps.onKeyDown(evt);
},
};
}, [itemProps, wrapSetOpen]);
const getMenuItemProps = useCallback(
({onClick, ref}: {onClick?: () => void; ref?: (node: any) => void} = {}) => {
const menuItemProps = itemProps.getItemProps();
return {
...menuItemProps,
ref: (node: any) => {
if (ref) {
ref(node);
}
menuItemProps.ref(node);
},
onClick: (evt: React.MouseEvent) => {
evt.preventDefault();
onClick?.();
},
onKeyDown: (evt: React.KeyboardEvent) => {
if (evt.key === 'Escape') {
wrapSetOpen(false);
}
if (evt.key === 'Enter') {
onClick?.();
}
menuItemProps.onKeyDown(evt);
},
};
},
[itemProps, wrapSetOpen]
);
const handleContextMenu = useCallback(
(evt: React.MouseEvent) => {
if (!container) {
return;
}
evt.preventDefault();
evt.stopPropagation();
const parentPosition = container.getBoundingClientRect();
setContextMenuCoordinates(
new Rect(
evt.clientX - parentPosition.left,
evt.clientY - parentPosition.top,
0,
0
)
);
wrapSetOpen(true);
},
[wrapSetOpen, container]
);
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if clicking ref's element or descendent elements
if (
!itemProps.menuRef ||
itemProps.menuRef.contains(event.target as Node) ||
subMenuRef.current?.contains(event.target as Node)
) {
return;
}
setOpen(false);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [itemProps.menuRef]);
// Observe the menu
useEffect(() => {
if (!itemProps.menuRef) {
return undefined;
}
const resizeObserver = new window.ResizeObserver(entries => {
const contentRect = entries[0].contentRect;
setMenuCoordinates(new Rect(0, 0, contentRect.width, contentRect.height));
});
resizeObserver.observe(itemProps.menuRef);
return () => {
resizeObserver.disconnect();
};
}, [itemProps.menuRef]);
// Observe the container
useEffect(() => {
if (!container) {
return undefined;
}
const resizeObserver = new window.ResizeObserver(entries => {
const contentRect = entries[0].contentRect;
setContainerCoordinates(new Rect(0, 0, contentRect.width, contentRect.height));
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [container]);
const position =
contextMenuCoordinates && containerCoordinates && menuCoordinates
? computeBestContextMenuPosition(
contextMenuCoordinates,
containerCoordinates,
menuCoordinates
)
: null;
return {
open,
setOpen: wrapSetOpen,
position,
containerCoordinates,
contextMenuCoordinates: position,
menuRef: itemProps.menuRef,
subMenuRef,
handleContextMenu,
getMenuProps,
getMenuItemProps,
};
}