splitPanel.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import {DOMAttributes, ReactNode, useCallback, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {IconGrabbable} from 'sentry/icons';
  4. import space from 'sentry/styles/space';
  5. import useMouseTracking from 'sentry/utils/replays/hooks/useMouseTracking';
  6. const BAR_THICKNESS = 16;
  7. const HALF_BAR = BAR_THICKNESS / 2;
  8. const MOUSE_RELEASE_TIMEOUT_MS = 200;
  9. type CSSValue = `${number}px` | `${number}%`;
  10. type LimitValue =
  11. | {
  12. /**
  13. * Percent, as a value from `0` to `1.0`
  14. */
  15. pct: number;
  16. }
  17. | {
  18. /**
  19. * CSS pixels
  20. */
  21. px: number;
  22. };
  23. type Side =
  24. | ReactNode
  25. | {
  26. content: ReactNode;
  27. default?: CSSValue;
  28. max?: LimitValue;
  29. min?: LimitValue;
  30. };
  31. type Props =
  32. | {
  33. /**
  34. * Content on the right side of the split
  35. */
  36. left: Side;
  37. /**
  38. * Content on the left side of the split
  39. */
  40. right: Side;
  41. }
  42. | {
  43. /**
  44. * Content below the split
  45. */
  46. bottom: Side;
  47. /**
  48. * Content above of the split
  49. */
  50. top: Side;
  51. };
  52. const getValFromSide = (side: Side, field: string) =>
  53. side && typeof side === 'object' && field in side ? side[field] : undefined;
  54. function getSplitDefault(props: Props) {
  55. if ('left' in props) {
  56. const a = getValFromSide(props.left, 'default');
  57. if (a) {
  58. return {a};
  59. }
  60. const b = getValFromSide(props.right, 'default');
  61. if (b) {
  62. return {b};
  63. }
  64. return {a: '50%'};
  65. }
  66. const a = getValFromSide(props.top, 'default');
  67. if (a) {
  68. return {a};
  69. }
  70. const b = getValFromSide(props.bottom, 'default');
  71. if (b) {
  72. return {b};
  73. }
  74. return {a: '50%'};
  75. }
  76. function getMinMax(side: Side): {
  77. max: {pct: number; px: number};
  78. min: {pct: number; px: number};
  79. } {
  80. const ONE = {px: Number.MAX_SAFE_INTEGER, pct: 1.0};
  81. const ZERO = {px: 0, pct: 0};
  82. if (!side || typeof side !== 'object') {
  83. return {
  84. max: ONE,
  85. min: ZERO,
  86. };
  87. }
  88. return {
  89. max: 'max' in side ? {...ONE, ...side.max} : ONE,
  90. min: 'min' in side ? {...ZERO, ...side.min} : ZERO,
  91. };
  92. }
  93. function useTimeout({timeMs, callback}: {callback: () => void; timeMs: number}) {
  94. const timeoutRef = useRef<number>(null);
  95. const saveTimeout = useCallback((timeout: ReturnType<typeof setTimeout> | null) => {
  96. if (timeoutRef.current) {
  97. clearTimeout(timeoutRef.current);
  98. }
  99. // See: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
  100. // @ts-expect-error
  101. timeoutRef.current = timeout;
  102. }, []);
  103. return {
  104. start: () => saveTimeout(setTimeout(callback, timeMs)),
  105. stop: () => saveTimeout(null),
  106. };
  107. }
  108. function SplitPanel(props: Props) {
  109. const [mousedown, setMousedown] = useState(false);
  110. const [sizeCSS, setSizeCSS] = useState(getSplitDefault(props));
  111. const {start: startMouseIdleTimer, stop: stopMouseIdleTimer} = useTimeout({
  112. timeMs: MOUSE_RELEASE_TIMEOUT_MS,
  113. callback: () => setMousedown(false),
  114. });
  115. const handleMouseDown = useCallback(() => {
  116. setMousedown(true);
  117. document.addEventListener(
  118. 'mouseup',
  119. () => {
  120. setMousedown(false);
  121. stopMouseIdleTimer();
  122. },
  123. {once: true}
  124. );
  125. startMouseIdleTimer();
  126. }, [startMouseIdleTimer, stopMouseIdleTimer]);
  127. const handlePositionChange = useCallback(
  128. params => {
  129. if (mousedown && params) {
  130. startMouseIdleTimer();
  131. const {left, top, width, height} = params;
  132. if ('left' in props) {
  133. const priPx = left - HALF_BAR;
  134. const priPct = priPx / width;
  135. const secPx = width - priPx;
  136. const secPct = 1 - priPct;
  137. const priLim = getMinMax(props.left);
  138. const secLim = getMinMax(props.right);
  139. if (
  140. priPx < priLim.min.px ||
  141. priPx > priLim.max.px ||
  142. priPct < priLim.min.pct ||
  143. priPct > priLim.max.pct ||
  144. secPx < secLim.min.px ||
  145. secPx > secLim.max.px ||
  146. secPct < secLim.min.pct ||
  147. secPct > secLim.max.pct
  148. ) {
  149. return;
  150. }
  151. setSizeCSS({a: `${priPct * 100}%`});
  152. } else {
  153. const priPx = top - HALF_BAR;
  154. const priPct = priPx / height;
  155. const secPx = height - priPx;
  156. const secPct = 1 - priPct;
  157. const priLim = getMinMax(props.top);
  158. const secLim = getMinMax(props.bottom);
  159. if (
  160. priPx < priLim.min.px ||
  161. priPx > priLim.max.px ||
  162. priPct < priLim.min.pct ||
  163. priPct > priLim.max.pct ||
  164. secPx < secLim.min.px ||
  165. secPx > secLim.max.px ||
  166. secPct < secLim.min.pct ||
  167. secPct > secLim.max.pct
  168. ) {
  169. return;
  170. }
  171. setSizeCSS({a: `${priPct * 100}%`});
  172. }
  173. }
  174. },
  175. [mousedown, props, startMouseIdleTimer]
  176. );
  177. const mouseTrackingProps = useMouseTracking<HTMLDivElement>({
  178. onPositionChange: handlePositionChange,
  179. });
  180. if ('left' in props) {
  181. const {left: a, right: b} = props;
  182. return (
  183. <SplitPanelContainer orientation="columns" size={sizeCSS} {...mouseTrackingProps}>
  184. <Panel>{getValFromSide(a, 'content') || a}</Panel>
  185. <Divider
  186. slideDirection="leftright"
  187. mousedown={mousedown}
  188. onMouseDown={handleMouseDown}
  189. />
  190. <Panel>{getValFromSide(b, 'content') || b}</Panel>
  191. </SplitPanelContainer>
  192. );
  193. }
  194. const {top: a, bottom: b} = props;
  195. return (
  196. <SplitPanelContainer orientation="rows" size={sizeCSS} {...mouseTrackingProps}>
  197. <Panel>{getValFromSide(a, 'content') || a}</Panel>
  198. <Divider
  199. slideDirection="updown"
  200. onMouseDown={() => setMousedown(true)}
  201. onMouseUp={() => setMousedown(false)}
  202. mousedown={mousedown}
  203. />
  204. <Panel>{getValFromSide(b, 'content') || b}</Panel>
  205. </SplitPanelContainer>
  206. );
  207. }
  208. const SplitPanelContainer = styled('div')<{
  209. orientation: 'rows' | 'columns';
  210. size: {a: CSSValue} | {b: CSSValue};
  211. }>`
  212. width: 100%;
  213. height: 100%;
  214. display: grid;
  215. overflow: auto;
  216. grid-template-${p => p.orientation}:
  217. ${p => ('a' in p.size ? p.size.a : '1fr')}
  218. auto
  219. ${p => ('a' in p.size ? '1fr' : p.size.b)};
  220. `;
  221. const Panel = styled('div')`
  222. overflow: hidden;
  223. `;
  224. type DividerProps = {mousedown: boolean; slideDirection: 'leftright' | 'updown'};
  225. const Divider = styled(
  226. ({
  227. mousedown: _a,
  228. slideDirection: _b,
  229. ...props
  230. }: DividerProps & DOMAttributes<HTMLDivElement>) => (
  231. <div {...props}>
  232. <IconGrabbable size="sm" />
  233. </div>
  234. )
  235. )<DividerProps>`
  236. display: grid;
  237. place-items: center;
  238. height: 100%;
  239. width: 100%;
  240. ${p => (p.mousedown ? 'user-select: none;' : '')}
  241. :hover {
  242. background: ${p => p.theme.hover};
  243. }
  244. ${p =>
  245. p.slideDirection === 'leftright'
  246. ? `
  247. cursor: ew-resize;
  248. height: 100%;
  249. width: ${space(2)};
  250. `
  251. : `
  252. cursor: ns-resize;
  253. width: 100%;
  254. height: ${space(2)};
  255. & > svg {
  256. transform: rotate(90deg);
  257. }
  258. `}
  259. `;
  260. export default SplitPanel;