splitPanel.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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. import useSplitPanelTracking from 'sentry/utils/replays/hooks/useSplitPanelTracking';
  7. import useTimeout from 'sentry/utils/useTimeout';
  8. const BAR_THICKNESS = 16;
  9. const HALF_BAR = BAR_THICKNESS / 2;
  10. const MOUSE_RELEASE_TIMEOUT_MS = 750;
  11. type CSSValuePX = `${number}px`;
  12. type CSSValuePct = `${number}%`;
  13. type CSSValueFR = '1fr';
  14. type CSSValue = CSSValuePX | CSSValuePct | CSSValueFR;
  15. type LimitValue =
  16. | {
  17. /**
  18. * Percent, as a value from `0` to `1.0`
  19. */
  20. pct: number;
  21. }
  22. | {
  23. /**
  24. * CSS pixels
  25. */
  26. px: number;
  27. };
  28. type Side = {
  29. content: ReactNode;
  30. default?: CSSValuePct | CSSValueFR;
  31. max?: LimitValue;
  32. min?: LimitValue;
  33. };
  34. type Props =
  35. | {
  36. /**
  37. * Content on the right side of the split
  38. */
  39. left: Side;
  40. /**
  41. * Content on the left side of the split
  42. */
  43. right: Side;
  44. }
  45. | {
  46. /**
  47. * Content below the split
  48. */
  49. bottom: Side;
  50. /**
  51. * Content above of the split
  52. */
  53. top: Side;
  54. };
  55. function getValFromSide<Field extends keyof Side>(side: Side, field: Field) {
  56. return side && typeof side === 'object' && field in side ? side[field] : undefined;
  57. }
  58. function getSplitDefault(props: Props) {
  59. if ('left' in props) {
  60. const a = getValFromSide(props.left, 'default');
  61. if (a) {
  62. return {a};
  63. }
  64. const b = getValFromSide(props.right, 'default');
  65. if (b) {
  66. return {b};
  67. }
  68. return {a: '50%' as CSSValuePct};
  69. }
  70. const a = getValFromSide(props.top, 'default');
  71. if (a) {
  72. return {a};
  73. }
  74. const b = getValFromSide(props.bottom, 'default');
  75. if (b) {
  76. return {b};
  77. }
  78. return {a: '50%' as CSSValuePct};
  79. }
  80. function getMinMax(side: Side): {
  81. max: {pct: number; px: number};
  82. min: {pct: number; px: number};
  83. } {
  84. const ONE = {px: Number.MAX_SAFE_INTEGER, pct: 1.0};
  85. const ZERO = {px: 0, pct: 0};
  86. if (!side || typeof side !== 'object') {
  87. return {
  88. max: ONE,
  89. min: ZERO,
  90. };
  91. }
  92. return {
  93. max: 'max' in side ? {...ONE, ...side.max} : ONE,
  94. min: 'min' in side ? {...ZERO, ...side.min} : ZERO,
  95. };
  96. }
  97. function SplitPanel(props: Props) {
  98. const [isMousedown, setIsMousedown] = useState(false);
  99. const [sizeCSS, setSizeCSS] = useState(getSplitDefault(props));
  100. const sizeCSSRef = useRef<undefined | CSSValuePct | CSSValueFR>();
  101. sizeCSSRef.current = sizeCSS.a;
  102. const {setStartPosition, logEndPosition} = useSplitPanelTracking({
  103. slideDirection: 'left' in props ? 'leftright' : 'updown',
  104. });
  105. const onTimeout = useCallback(() => {
  106. setIsMousedown(false);
  107. logEndPosition(sizeCSSRef.current);
  108. }, [logEndPosition]);
  109. const {start: startMouseIdleTimer, cancel: cancelMouseIdleTimer} = useTimeout({
  110. timeMs: MOUSE_RELEASE_TIMEOUT_MS,
  111. onTimeout,
  112. });
  113. const handleMouseDown = useCallback(() => {
  114. setIsMousedown(true);
  115. setStartPosition(sizeCSSRef.current);
  116. document.addEventListener(
  117. 'mouseup',
  118. () => {
  119. setIsMousedown(false);
  120. cancelMouseIdleTimer();
  121. logEndPosition(sizeCSSRef.current);
  122. },
  123. {once: true}
  124. );
  125. startMouseIdleTimer();
  126. }, [cancelMouseIdleTimer, logEndPosition, setStartPosition, startMouseIdleTimer]);
  127. const handlePositionChange = useCallback(
  128. params => {
  129. if (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. [props, startMouseIdleTimer]
  176. );
  177. const elem = useRef<HTMLDivElement>(null);
  178. const mouseTrackingProps = useMouseTracking<HTMLDivElement>({
  179. elem,
  180. onPositionChange: handlePositionChange,
  181. });
  182. const activeTrackingProps = isMousedown ? mouseTrackingProps : {};
  183. if ('left' in props) {
  184. const {left: a, right: b} = props;
  185. return (
  186. <SplitPanelContainer
  187. orientation="columns"
  188. size={sizeCSS}
  189. ref={elem}
  190. {...activeTrackingProps}
  191. >
  192. <Panel>{getValFromSide(a, 'content') || a}</Panel>
  193. <Divider
  194. slideDirection="leftright"
  195. isMousedown={isMousedown}
  196. onMouseDown={handleMouseDown}
  197. />
  198. <Panel>{getValFromSide(b, 'content') || b}</Panel>
  199. {isMousedown ? <HoverMouseDiv /> : null}
  200. </SplitPanelContainer>
  201. );
  202. }
  203. const {top: a, bottom: b} = props;
  204. return (
  205. <SplitPanelContainer
  206. orientation="rows"
  207. size={sizeCSS}
  208. ref={elem}
  209. {...activeTrackingProps}
  210. >
  211. <Panel>{getValFromSide(a, 'content') || a}</Panel>
  212. <Divider
  213. slideDirection="updown"
  214. isMousedown={isMousedown}
  215. onMouseDown={handleMouseDown}
  216. />
  217. <Panel>{getValFromSide(b, 'content') || b}</Panel>
  218. {isMousedown ? <HoverMouseDiv /> : null}
  219. </SplitPanelContainer>
  220. );
  221. }
  222. const SplitPanelContainer = styled('div')<{
  223. orientation: 'rows' | 'columns';
  224. size: {a: CSSValue} | {b: CSSValue};
  225. }>`
  226. width: 100%;
  227. height: 100%;
  228. position: relative;
  229. display: grid;
  230. overflow: auto;
  231. grid-template-${p => p.orientation}:
  232. ${p => ('a' in p.size ? p.size.a : '1fr')}
  233. auto
  234. ${p => ('a' in p.size ? '1fr' : p.size.b)};
  235. `;
  236. const Panel = styled('div')`
  237. overflow: hidden;
  238. `;
  239. const HoverMouseDiv = styled('div')`
  240. position: absolute;
  241. top: 0;
  242. left: 0;
  243. width: 100%;
  244. height: 100%;
  245. `;
  246. type DividerProps = {isMousedown: boolean; slideDirection: 'leftright' | 'updown'};
  247. const Divider = styled(
  248. ({
  249. isMousedown: _a,
  250. slideDirection: _b,
  251. ...props
  252. }: DividerProps & DOMAttributes<HTMLDivElement>) => (
  253. <div {...props}>
  254. <IconGrabbable size="sm" />
  255. </div>
  256. )
  257. )<DividerProps>`
  258. display: grid;
  259. place-items: center;
  260. height: 100%;
  261. width: 100%;
  262. ${p => (p.isMousedown ? 'user-select: none;' : '')}
  263. :hover {
  264. background: ${p => p.theme.hover};
  265. }
  266. ${p =>
  267. p.slideDirection === 'leftright'
  268. ? `
  269. cursor: ew-resize;
  270. height: 100%;
  271. width: ${space(2)};
  272. `
  273. : `
  274. cursor: ns-resize;
  275. width: 100%;
  276. height: ${space(2)};
  277. & > svg {
  278. transform: rotate(90deg);
  279. }
  280. `}
  281. `;
  282. export default SplitPanel;