useContextMenu.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import {useCallback, useEffect, useState} from 'react';
  2. import {clamp} from 'sentry/utils/profiling/colors/utils';
  3. import {Rect} from 'sentry/utils/profiling/gl/utils';
  4. import {useKeyboardNavigation} from './useKeyboardNavigation';
  5. export function computeBestContextMenuPosition(
  6. mouse: Rect,
  7. container: Rect,
  8. target: Rect
  9. ) {
  10. const maxY = Math.floor(container.height - target.height);
  11. const minY = container.top;
  12. const minX = container.left;
  13. const maxX = Math.floor(container.right - target.width);
  14. // We add a tiny offset so that the menu is not directly where the user places their cursor.
  15. const OFFSET = 6;
  16. return {
  17. left: clamp(mouse.x + OFFSET, minX, maxX),
  18. top: clamp(mouse.y + OFFSET, minY, maxY),
  19. };
  20. }
  21. interface UseContextMenuOptions {
  22. container: HTMLElement | null;
  23. }
  24. export function useContextMenu({container}: UseContextMenuOptions) {
  25. const [open, setOpen] = useState<boolean>(false);
  26. const [menuCoordinates, setMenuCoordinates] = useState<Rect | null>(null);
  27. const [contextMenuCoordinates, setContextMenuCoordinates] = useState<Rect | null>(null);
  28. const [containerCoordinates, setContainerCoordinates] = useState<Rect | null>(null);
  29. const itemProps = useKeyboardNavigation();
  30. // We wrap the setOpen function in a useEffect so that we also clear the keyboard
  31. // tabIndex when a menu is closed. This prevents tabIndex from being persisted between render
  32. const wrapSetOpen = useCallback(
  33. (newOpen: boolean) => {
  34. if (!newOpen) {
  35. itemProps.setTabIndex(null);
  36. }
  37. setOpen(newOpen);
  38. },
  39. [itemProps]
  40. );
  41. const getMenuProps = useCallback(() => {
  42. const menuProps = itemProps.getMenuProps();
  43. return {
  44. ...menuProps,
  45. onKeyDown: (evt: React.KeyboardEvent) => {
  46. if (evt.key === 'Escape') {
  47. wrapSetOpen(false);
  48. }
  49. menuProps.onKeyDown(evt);
  50. },
  51. };
  52. }, [itemProps, wrapSetOpen]);
  53. const getMenuItemProps = useCallback(() => {
  54. const menuItemProps = itemProps.getItemProps();
  55. return {
  56. ...menuItemProps,
  57. onKeyDown: (evt: React.KeyboardEvent) => {
  58. if (evt.key === 'Escape') {
  59. wrapSetOpen(false);
  60. }
  61. menuItemProps.onKeyDown(evt);
  62. },
  63. };
  64. }, [itemProps, wrapSetOpen]);
  65. const handleContextMenu = useCallback(
  66. (evt: React.MouseEvent) => {
  67. if (!container) {
  68. return;
  69. }
  70. evt.preventDefault();
  71. evt.stopPropagation();
  72. const parentPosition = container.getBoundingClientRect();
  73. setContextMenuCoordinates(
  74. new Rect(
  75. evt.clientX - parentPosition.left,
  76. evt.clientY - parentPosition.top,
  77. 0,
  78. 0
  79. )
  80. );
  81. wrapSetOpen(true);
  82. },
  83. [wrapSetOpen, container]
  84. );
  85. useEffect(() => {
  86. const listener = (event: MouseEvent | TouchEvent) => {
  87. // Do nothing if clicking ref's element or descendent elements
  88. if (!itemProps.menuRef || itemProps.menuRef.contains(event.target as Node)) {
  89. return;
  90. }
  91. setOpen(false);
  92. };
  93. document.addEventListener('mousedown', listener);
  94. document.addEventListener('touchstart', listener);
  95. return () => {
  96. document.removeEventListener('mousedown', listener);
  97. document.removeEventListener('touchstart', listener);
  98. };
  99. }, [itemProps.menuRef]);
  100. // Observe the menu
  101. useEffect(() => {
  102. if (!itemProps.menuRef) {
  103. return undefined;
  104. }
  105. const resizeObserver = new window.ResizeObserver(entries => {
  106. const contentRect = entries[0].contentRect;
  107. setMenuCoordinates(new Rect(0, 0, contentRect.width, contentRect.height));
  108. });
  109. resizeObserver.observe(itemProps.menuRef);
  110. return () => {
  111. resizeObserver.disconnect();
  112. };
  113. }, [itemProps.menuRef]);
  114. // Observe the container
  115. useEffect(() => {
  116. if (!container) {
  117. return undefined;
  118. }
  119. const resizeObserver = new window.ResizeObserver(entries => {
  120. const contentRect = entries[0].contentRect;
  121. setContainerCoordinates(new Rect(0, 0, contentRect.width, contentRect.height));
  122. });
  123. resizeObserver.observe(container);
  124. return () => {
  125. resizeObserver.disconnect();
  126. };
  127. }, [container]);
  128. const position =
  129. contextMenuCoordinates && containerCoordinates && menuCoordinates
  130. ? computeBestContextMenuPosition(
  131. contextMenuCoordinates,
  132. containerCoordinates,
  133. menuCoordinates
  134. )
  135. : null;
  136. return {
  137. open,
  138. setOpen: wrapSetOpen,
  139. position,
  140. containerCoordinates,
  141. contextMenuCoordinates: position,
  142. menuRef: itemProps.menuRef,
  143. handleContextMenu,
  144. getMenuProps,
  145. getMenuItemProps,
  146. };
  147. }