splitPanel.tsx 7.2 KB

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