replaySliderDiff.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import {Fragment, useCallback, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import NegativeSpaceContainer from 'sentry/components/container/negativeSpaceContainer';
  4. import ReplayPlayer from 'sentry/components/replays/player/replayPlayer';
  5. import ReplayPlayerMeasurer from 'sentry/components/replays/player/replayPlayerMeasurer';
  6. import {Tooltip} from 'sentry/components/tooltip';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. import {trackAnalytics} from 'sentry/utils/analytics';
  10. import toPixels from 'sentry/utils/number/toPixels';
  11. import {ReplayPlayerEventsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerEventsContext';
  12. import {ReplayPlayerPluginsContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerPluginsContext';
  13. import {ReplayPlayerStateContextProvider} from 'sentry/utils/replays/playback/providers/replayPlayerStateContext';
  14. import type ReplayReader from 'sentry/utils/replays/replayReader';
  15. import {useDimensions} from 'sentry/utils/useDimensions';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. import {useResizableDrawer} from 'sentry/utils/useResizableDrawer';
  18. interface Props {
  19. leftOffsetMs: number;
  20. replay: null | ReplayReader;
  21. rightOffsetMs: number;
  22. minHeight?: `${number}px` | `${number}%`;
  23. }
  24. export function ReplaySliderDiff({
  25. minHeight = '0px',
  26. leftOffsetMs,
  27. replay,
  28. rightOffsetMs,
  29. }: Props) {
  30. const positionedRef = useRef<HTMLDivElement>(null);
  31. const viewDimensions = useDimensions({elementRef: positionedRef});
  32. const width = toPixels(viewDimensions.width);
  33. return (
  34. <Fragment>
  35. <Header>
  36. <Tooltip title={t('How the initial server-rendered page looked.')}>
  37. <div style={{color: 'red'}}>{t('Before')}</div>
  38. </Tooltip>
  39. <Tooltip
  40. title={t(
  41. 'How React re-rendered the page on your browser, after detecting a hydration error.'
  42. )}
  43. >
  44. <div style={{color: 'green'}}>{t('After')}</div>
  45. </Tooltip>
  46. </Header>
  47. <WithPadding>
  48. <Positioned style={{minHeight}} ref={positionedRef}>
  49. {viewDimensions.width ? (
  50. <DiffSides
  51. leftOffsetMs={leftOffsetMs}
  52. replay={replay}
  53. rightOffsetMs={rightOffsetMs}
  54. viewDimensions={viewDimensions}
  55. width={width}
  56. />
  57. ) : (
  58. <div />
  59. )}
  60. </Positioned>
  61. </WithPadding>
  62. </Fragment>
  63. );
  64. }
  65. function DiffSides({leftOffsetMs, replay, rightOffsetMs, viewDimensions, width}) {
  66. const rightSideElem = useRef<HTMLDivElement>(null);
  67. const dividerElem = useRef<HTMLDivElement>(null);
  68. const {onMouseDown: onDividerMouseDown} = useResizableDrawer({
  69. direction: 'left',
  70. initialSize: viewDimensions.width / 2,
  71. min: 0,
  72. onResize: newSize => {
  73. if (rightSideElem.current) {
  74. rightSideElem.current.style.width =
  75. viewDimensions.width === 0
  76. ? '100%'
  77. : toPixels(Math.min(viewDimensions.width, viewDimensions.width - newSize)) ??
  78. '0px';
  79. }
  80. if (dividerElem.current) {
  81. dividerElem.current.style.left =
  82. toPixels(Math.min(viewDimensions.width, newSize)) ?? '0px';
  83. }
  84. },
  85. });
  86. const organization = useOrganization();
  87. const dividerClickedRef = useRef(false); // once set, never flips back to false
  88. const onDividerMouseDownWithAnalytics: React.MouseEventHandler<HTMLElement> =
  89. useCallback(
  90. (event: React.MouseEvent<HTMLElement>) => {
  91. // tracks only the first mouseDown since the last render
  92. if (organization && !dividerClickedRef.current) {
  93. trackAnalytics('replay.hydration-modal.slider-interaction', {organization});
  94. dividerClickedRef.current = true;
  95. }
  96. onDividerMouseDown(event);
  97. },
  98. [onDividerMouseDown, organization]
  99. );
  100. return (
  101. <Fragment>
  102. <ReplayPlayerPluginsContextProvider>
  103. <ReplayPlayerEventsContextProvider replay={replay}>
  104. <Cover style={{width}}>
  105. <Placement style={{width}}>
  106. <ReplayPlayerStateContextProvider>
  107. <NegativeSpaceContainer style={{height: '100%'}}>
  108. <ReplayPlayerMeasurer measure="both">
  109. {style => <ReplayPlayer style={style} offsetMs={leftOffsetMs} />}
  110. </ReplayPlayerMeasurer>
  111. </NegativeSpaceContainer>
  112. </ReplayPlayerStateContextProvider>
  113. </Placement>
  114. </Cover>
  115. <Cover ref={rightSideElem} style={{width: 0}}>
  116. <Placement style={{width}}>
  117. <ReplayPlayerStateContextProvider>
  118. <NegativeSpaceContainer style={{height: '100%'}}>
  119. <ReplayPlayerMeasurer measure="both">
  120. {style => <ReplayPlayer style={style} offsetMs={rightOffsetMs} />}
  121. </ReplayPlayerMeasurer>
  122. </NegativeSpaceContainer>
  123. </ReplayPlayerStateContextProvider>
  124. </Placement>
  125. </Cover>
  126. </ReplayPlayerEventsContextProvider>
  127. </ReplayPlayerPluginsContextProvider>
  128. <Divider ref={dividerElem} onMouseDown={onDividerMouseDownWithAnalytics} />
  129. </Fragment>
  130. );
  131. }
  132. const WithPadding = styled(NegativeSpaceContainer)`
  133. padding-block: ${space(1.5)};
  134. overflow: visible;
  135. height: 100%;
  136. `;
  137. const Positioned = styled('div')`
  138. height: 100%;
  139. position: relative;
  140. width: 100%;
  141. `;
  142. const Cover = styled('div')`
  143. border: 1px solid;
  144. height: 100%;
  145. overflow: hidden;
  146. position: absolute;
  147. right: 0px;
  148. top: 0px;
  149. border-color: red;
  150. & + & {
  151. border-color: green;
  152. border-left-color: transparent;
  153. }
  154. `;
  155. const Placement = styled('div')`
  156. display: flex;
  157. height: 100%;
  158. justify-content: center;
  159. position: absolute;
  160. right: 0;
  161. top: 0;
  162. place-items: center;
  163. `;
  164. const Divider = styled('div')`
  165. --handle-size: ${space(1.5)};
  166. --line-width: 1px;
  167. cursor: ew-resize;
  168. width: var(--line-width);
  169. height: 100%;
  170. background: ${p => p.theme.purple400};
  171. position: absolute;
  172. top: 0;
  173. transform: translate(-0.5px, 0);
  174. &::before,
  175. &::after {
  176. background: ${p => p.theme.purple400};
  177. border-radius: var(--handle-size);
  178. border: var(--line-width) solid ${p => p.theme.purple400};
  179. content: '';
  180. height: var(--handle-size);
  181. position: absolute;
  182. width: var(--handle-size);
  183. z-index: 1;
  184. }
  185. &::before {
  186. top: 0;
  187. transform: translate(calc(var(--handle-size) / -2 + var(--line-width) / 2), -100%);
  188. }
  189. &::after {
  190. bottom: 0;
  191. transform: translate(calc(var(--handle-size) / -2 + var(--line-width) / 2), 100%);
  192. }
  193. `;
  194. const Header = styled('div')`
  195. display: flex;
  196. justify-content: space-between;
  197. align-items: center;
  198. `;