useContextMenu.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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. ({onClick}: {onClick?: () => void} = {}) => {
  55. const menuItemProps = itemProps.getItemProps();
  56. return {
  57. ...menuItemProps,
  58. onClick: (evt: React.MouseEvent) => {
  59. evt.preventDefault();
  60. onClick?.();
  61. },
  62. onKeyDown: (evt: React.KeyboardEvent) => {
  63. if (evt.key === 'Escape') {
  64. wrapSetOpen(false);
  65. }
  66. if (evt.key === 'Enter') {
  67. onClick?.();
  68. }
  69. menuItemProps.onKeyDown(evt);
  70. },
  71. };
  72. },
  73. [itemProps, wrapSetOpen]
  74. );
  75. const handleContextMenu = useCallback(
  76. (evt: React.MouseEvent) => {
  77. if (!container) {
  78. return;
  79. }
  80. evt.preventDefault();
  81. evt.stopPropagation();
  82. const parentPosition = container.getBoundingClientRect();
  83. setContextMenuCoordinates(
  84. new Rect(
  85. evt.clientX - parentPosition.left,
  86. evt.clientY - parentPosition.top,
  87. 0,
  88. 0
  89. )
  90. );
  91. wrapSetOpen(true);
  92. },
  93. [wrapSetOpen, container]
  94. );
  95. useEffect(() => {
  96. const listener = (event: MouseEvent | TouchEvent) => {
  97. // Do nothing if clicking ref's element or descendent elements
  98. if (!itemProps.menuRef || itemProps.menuRef.contains(event.target as Node)) {
  99. return;
  100. }
  101. setOpen(false);
  102. };
  103. document.addEventListener('mousedown', listener);
  104. document.addEventListener('touchstart', listener);
  105. return () => {
  106. document.removeEventListener('mousedown', listener);
  107. document.removeEventListener('touchstart', listener);
  108. };
  109. }, [itemProps.menuRef]);
  110. // Observe the menu
  111. useEffect(() => {
  112. if (!itemProps.menuRef) {
  113. return undefined;
  114. }
  115. const resizeObserver = new window.ResizeObserver(entries => {
  116. const contentRect = entries[0].contentRect;
  117. setMenuCoordinates(new Rect(0, 0, contentRect.width, contentRect.height));
  118. });
  119. resizeObserver.observe(itemProps.menuRef);
  120. return () => {
  121. resizeObserver.disconnect();
  122. };
  123. }, [itemProps.menuRef]);
  124. // Observe the container
  125. useEffect(() => {
  126. if (!container) {
  127. return undefined;
  128. }
  129. const resizeObserver = new window.ResizeObserver(entries => {
  130. const contentRect = entries[0].contentRect;
  131. setContainerCoordinates(new Rect(0, 0, contentRect.width, contentRect.height));
  132. });
  133. resizeObserver.observe(container);
  134. return () => {
  135. resizeObserver.disconnect();
  136. };
  137. }, [container]);
  138. const position =
  139. contextMenuCoordinates && containerCoordinates && menuCoordinates
  140. ? computeBestContextMenuPosition(
  141. contextMenuCoordinates,
  142. containerCoordinates,
  143. menuCoordinates
  144. )
  145. : null;
  146. return {
  147. open,
  148. setOpen: wrapSetOpen,
  149. position,
  150. containerCoordinates,
  151. contextMenuCoordinates: position,
  152. menuRef: itemProps.menuRef,
  153. handleContextMenu,
  154. getMenuProps,
  155. getMenuItemProps,
  156. };
  157. }