splitPanel.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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. const activeTrackingProps = mousedown ? mouseTrackingProps : {};
  181. if ('left' in props) {
  182. const {left: a, right: b} = props;
  183. return (
  184. <SplitPanelContainer orientation="columns" size={sizeCSS} {...activeTrackingProps}>
  185. <Panel>{getValFromSide(a, 'content') || a}</Panel>
  186. <Divider
  187. slideDirection="leftright"
  188. mousedown={mousedown}
  189. onMouseDown={handleMouseDown}
  190. />
  191. <Panel>{getValFromSide(b, 'content') || b}</Panel>
  192. </SplitPanelContainer>
  193. );
  194. }
  195. const {top: a, bottom: b} = props;
  196. return (
  197. <SplitPanelContainer orientation="rows" size={sizeCSS} {...activeTrackingProps}>
  198. <Panel>{getValFromSide(a, 'content') || a}</Panel>
  199. <Divider
  200. slideDirection="updown"
  201. onMouseDown={() => setMousedown(true)}
  202. onMouseUp={() => setMousedown(false)}
  203. mousedown={mousedown}
  204. />
  205. <Panel>{getValFromSide(b, 'content') || b}</Panel>
  206. </SplitPanelContainer>
  207. );
  208. }
  209. const SplitPanelContainer = styled('div')<{
  210. orientation: 'rows' | 'columns';
  211. size: {a: CSSValue} | {b: CSSValue};
  212. }>`
  213. width: 100%;
  214. height: 100%;
  215. display: grid;
  216. overflow: auto;
  217. grid-template-${p => p.orientation}:
  218. ${p => ('a' in p.size ? p.size.a : '1fr')}
  219. auto
  220. ${p => ('a' in p.size ? '1fr' : p.size.b)};
  221. `;
  222. const Panel = styled('div')`
  223. overflow: hidden;
  224. `;
  225. type DividerProps = {mousedown: boolean; slideDirection: 'leftright' | 'updown'};
  226. const Divider = styled(
  227. ({
  228. mousedown: _a,
  229. slideDirection: _b,
  230. ...props
  231. }: DividerProps & DOMAttributes<HTMLDivElement>) => (
  232. <div {...props}>
  233. <IconGrabbable size="sm" />
  234. </div>
  235. )
  236. )<DividerProps>`
  237. display: grid;
  238. place-items: center;
  239. height: 100%;
  240. width: 100%;
  241. ${p => (p.mousedown ? 'user-select: none;' : '')}
  242. :hover {
  243. background: ${p => p.theme.hover};
  244. }
  245. ${p =>
  246. p.slideDirection === 'leftright'
  247. ? `
  248. cursor: ew-resize;
  249. height: 100%;
  250. width: ${space(2)};
  251. `
  252. : `
  253. cursor: ns-resize;
  254. width: 100%;
  255. height: ${space(2)};
  256. & > svg {
  257. transform: rotate(90deg);
  258. }
  259. `}
  260. `;
  261. export default SplitPanel;