Browse Source

ref(replays): Refactor the Replay Details layout to reuse useResizableDrawer (#46077)

Ryan Albrecht 2 years ago
parent
commit
641f6e6f18

+ 78 - 69
static/app/views/replays/detail/layout/index.tsx

@@ -12,16 +12,19 @@ import FocusArea from 'sentry/views/replays/detail/focusArea';
 import FocusTabs from 'sentry/views/replays/detail/focusTabs';
 import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
 import FluidPanel from 'sentry/views/replays/detail/layout/fluidPanel';
+import MeasureSize from 'sentry/views/replays/detail/layout/measureSize';
 import SplitPanel from 'sentry/views/replays/detail/layout/splitPanel';
 import SideTabs from 'sentry/views/replays/detail/sideTabs';
 import TagPanel from 'sentry/views/replays/detail/tagPanel';
 
-const MIN_VIDEO_WIDTH = {px: 325};
-const MIN_CONTENT_WIDTH = {px: 325};
-const MIN_SIDEBAR_WIDTH = {px: 325};
-const MIN_VIDEO_HEIGHT = {px: 200};
-const MIN_CONTENT_HEIGHT = {px: 180};
-const MIN_SIDEBAR_HEIGHT = {px: 120};
+const MIN_VIDEO_WIDTH = 325;
+const MIN_CONTENT_WIDTH = 325;
+const MIN_SIDEBAR_WIDTH = 325;
+const MIN_VIDEO_HEIGHT = 200;
+const MIN_CONTENT_HEIGHT = 180;
+const MIN_SIDEBAR_HEIGHT = 120;
+
+const DIVIDER_SIZE = 16;
 
 type Props = {
   layout?: LayoutKey;
@@ -65,58 +68,61 @@ function ReplayLayout({layout = LayoutKey.topbar}: Props) {
     return (
       <BodyContent>
         {timeline}
-        <SplitPanel
-          key={layout}
-          left={{
-            content: focusArea,
-            default: '1fr',
-            min: MIN_CONTENT_WIDTH,
-          }}
-          right={{
-            content: <SideCrumbsTags />,
-            min: MIN_SIDEBAR_WIDTH,
-          }}
-        />
+        <MeasureSize>
+          {({width}) => (
+            <SplitPanel
+              key={layout}
+              availableSize={width}
+              left={{
+                content: focusArea,
+                default: (width - DIVIDER_SIZE) * 0.9,
+                min: 0,
+                max: width - DIVIDER_SIZE,
+              }}
+              right={<SideCrumbsTags />}
+            />
+          )}
+        </MeasureSize>
       </BodyContent>
     );
   }
 
-  const sideVideoCrumbs = (
-    <SplitPanel
-      key={layout}
-      top={{
-        content: video,
-        default: '65%',
-        min: MIN_CONTENT_WIDTH,
-      }}
-      bottom={{
-        content: <SideCrumbsTags />,
-        min: MIN_SIDEBAR_HEIGHT,
-      }}
-    />
-  );
-
   if (layout === LayoutKey.sidebar_left) {
     return (
       <BodyContent>
         {timeline}
-        <SplitPanel
-          key={layout}
-          left={{
-            content: sideVideoCrumbs,
-            min: MIN_SIDEBAR_WIDTH,
-          }}
-          right={{
-            content: focusArea,
-            default: '1fr',
-            min: MIN_CONTENT_WIDTH,
-          }}
-        />
+        <MeasureSize>
+          {({height, width}) => (
+            <SplitPanel
+              key={layout}
+              availableSize={width}
+              left={{
+                content: (
+                  <SplitPanel
+                    key={layout}
+                    availableSize={height}
+                    top={{
+                      content: video,
+                      default: (height - DIVIDER_SIZE) * 0.65,
+                      min: MIN_CONTENT_HEIGHT,
+                      max: height - DIVIDER_SIZE - MIN_SIDEBAR_HEIGHT,
+                    }}
+                    bottom={<SideCrumbsTags />}
+                  />
+                ),
+                default: (width - DIVIDER_SIZE) * 0.5,
+                min: MIN_SIDEBAR_WIDTH,
+                max: width - DIVIDER_SIZE - MIN_CONTENT_WIDTH,
+              }}
+              right={focusArea}
+            />
+          )}
+        </MeasureSize>
       </BodyContent>
     );
   }
 
-  // layout === 'topbar' or default
+  // layout === 'topbar'
   const crumbsWithTitle = (
     <ErrorBoundary mini>
       <Breadcrumbs showTitle />
@@ -126,29 +132,32 @@ function ReplayLayout({layout = LayoutKey.topbar}: Props) {
   return (
     <BodyContent>
       {timeline}
-      <SplitPanel
-        key={layout}
-        top={{
-          content: (
-            <SplitPanel
-              left={{
-                content: video,
-                default: '1fr',
-                min: MIN_VIDEO_WIDTH,
-              }}
-              right={{
-                content: crumbsWithTitle,
-              }}
-            />
-          ),
-          min: MIN_VIDEO_HEIGHT,
-        }}
-        bottom={{
-          content: focusArea,
-          default: '1fr',
-          min: MIN_CONTENT_HEIGHT,
-        }}
-      />
+      <MeasureSize>
+        {({height, width}) => (
+          <SplitPanel
+            key={layout}
+            availableSize={height}
+            top={{
+              content: (
+                <SplitPanel
+                  availableSize={width}
+                  left={{
+                    content: video,
+                    default: (width - DIVIDER_SIZE) * 0.5,
+                    min: MIN_VIDEO_WIDTH,
+                    max: width - DIVIDER_SIZE - MIN_SIDEBAR_WIDTH,
+                  }}
+                  right={crumbsWithTitle}
+                />
+              ),
+              default: (height - DIVIDER_SIZE) * 0.5,
+              min: MIN_VIDEO_HEIGHT,
+              max: height - DIVIDER_SIZE - MIN_CONTENT_HEIGHT,
+            }}
+            bottom={focusArea}
+          />
+        )}
+      </MeasureSize>
     </BodyContent>
   );
 }

+ 33 - 0
static/app/views/replays/detail/layout/measureSize.tsx

@@ -0,0 +1,33 @@
+import {ReactNode, useEffect, useRef, useState} from 'react';
+import styled from '@emotion/styled';
+
+type Sizes = {height: number; width: number};
+type ChildFn = (props: Sizes) => ReactNode;
+
+/**
+ * Similar to <AutoSizer> from 'react-virtualized` but works better with flex & grid parents
+ */
+const MeasureSize = styled(
+  ({children, className}: {children: ChildFn; className?: string}) => {
+    const outerRef = useRef<HTMLDivElement>(null);
+    const [sizes, setSizes] = useState<Sizes>();
+    useEffect(() => {
+      if (outerRef.current) {
+        const {height, width} = outerRef.current.getBoundingClientRect();
+        setSizes({height, width});
+      }
+    }, []);
+
+    return (
+      <div ref={outerRef} className={className}>
+        {sizes ? children(sizes) : null}
+      </div>
+    );
+  }
+)`
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+`;
+
+export default MeasureSize;

+ 63 - 195
static/app/views/replays/detail/layout/splitPanel.tsx

@@ -1,44 +1,22 @@
-import {DOMAttributes, ReactNode, useCallback, useRef, useState} from 'react';
+import {DOMAttributes, ReactNode, useCallback} from 'react';
 import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
 
 import {IconGrabbable} from 'sentry/icons';
 import {space} from 'sentry/styles/space';
-import useMouseTracking from 'sentry/utils/replays/hooks/useMouseTracking';
 import useSplitPanelTracking from 'sentry/utils/replays/hooks/useSplitPanelTracking';
-import useTimeout from 'sentry/utils/useTimeout';
-
-const BAR_THICKNESS = 16;
-const HALF_BAR = BAR_THICKNESS / 2;
-
-const MOUSE_RELEASE_TIMEOUT_MS = 750;
-
-type CSSValuePX = `${number}px`;
-type CSSValuePct = `${number}%`;
-type CSSValueFR = '1fr';
-type CSSValue = CSSValuePX | CSSValuePct | CSSValueFR;
-type LimitValue =
-  | {
-      /**
-       * Percent, as a value from `0` to `1.0`
-       */
-      pct: number;
-    }
-  | {
-      /**
-       * CSS pixels
-       */
-      px: number;
-    };
+import {useResizableDrawer} from 'sentry/utils/useResizableDrawer';
 
 type Side = {
   content: ReactNode;
-  default?: CSSValuePct | CSSValueFR;
-  max?: LimitValue;
-  min?: LimitValue;
+  default: number;
+  max: number;
+  min: number;
 };
 
 type Props =
   | {
+      availableSize: number;
       /**
        * Content on the right side of the split
        */
@@ -46,178 +24,76 @@ type Props =
       /**
        * Content on the left side of the split
        */
-      right: Side;
+      right: ReactNode;
     }
   | {
+      availableSize: number;
       /**
        * Content below the split
        */
-      bottom: Side;
+      bottom: ReactNode;
       /**
        * Content above of the split
        */
       top: Side;
     };
 
-function getValFromSide<Field extends keyof Side>(side: Side, field: Field) {
-  return side && typeof side === 'object' && field in side ? side[field] : undefined;
-}
-
-function getSplitDefault(props: Props) {
-  if ('left' in props) {
-    const a = getValFromSide(props.left, 'default');
-    if (a) {
-      return {a};
-    }
-    const b = getValFromSide(props.right, 'default');
-    if (b) {
-      return {b};
-    }
-    return {a: '50%' as CSSValuePct};
-  }
-  const a = getValFromSide(props.top, 'default');
-  if (a) {
-    return {a};
-  }
-  const b = getValFromSide(props.bottom, 'default');
-  if (b) {
-    return {b};
-  }
-  return {a: '50%' as CSSValuePct};
-}
-
-function getMinMax(side: Side): {
-  max: {pct: number; px: number};
-  min: {pct: number; px: number};
-} {
-  const ONE = {px: Number.MAX_SAFE_INTEGER, pct: 1.0};
-  const ZERO = {px: 0, pct: 0};
-  if (!side || typeof side !== 'object') {
-    return {
-      max: ONE,
-      min: ZERO,
-    };
-  }
-  return {
-    max: 'max' in side ? {...ONE, ...side.max} : ONE,
-    min: 'min' in side ? {...ZERO, ...side.min} : ZERO,
-  };
-}
-
 function SplitPanel(props: Props) {
-  const [isMousedown, setIsMousedown] = useState(false);
-  const [sizeCSS, setSizeCSS] = useState(getSplitDefault(props));
-  const sizeCSSRef = useRef<undefined | CSSValuePct | CSSValueFR>();
-  sizeCSSRef.current = sizeCSS.a;
+  const isLeftRight = 'left' in props;
+  const initialSize = isLeftRight ? props.left.default : props.top.default;
+  const min = isLeftRight ? props.left.min : props.top.min;
+  const max = isLeftRight ? props.left.max : props.top.max;
 
   const {setStartPosition, logEndPosition} = useSplitPanelTracking({
-    slideDirection: 'left' in props ? 'leftright' : 'updown',
-  });
-
-  const onTimeout = useCallback(() => {
-    setIsMousedown(false);
-    logEndPosition(sizeCSSRef.current);
-  }, [logEndPosition]);
-  const {start: startMouseIdleTimer, cancel: cancelMouseIdleTimer} = useTimeout({
-    timeMs: MOUSE_RELEASE_TIMEOUT_MS,
-    onTimeout,
+    slideDirection: isLeftRight ? 'leftright' : 'updown',
   });
 
-  const handleMouseDown = useCallback(() => {
-    setIsMousedown(true);
-    setStartPosition(sizeCSSRef.current);
-
-    document.addEventListener(
-      'mouseup',
-      () => {
-        setIsMousedown(false);
-        cancelMouseIdleTimer();
-        logEndPosition(sizeCSSRef.current);
-      },
-      {once: true}
-    );
-
-    startMouseIdleTimer();
-  }, [cancelMouseIdleTimer, logEndPosition, setStartPosition, startMouseIdleTimer]);
-
-  const handlePositionChange = useCallback(
-    params => {
-      if (params) {
-        startMouseIdleTimer();
-        const {left, top, width, height} = params;
-
-        if ('left' in props) {
-          const priPx = left - HALF_BAR;
-          const priPct = priPx / width;
-          const secPx = width - priPx;
-          const secPct = 1 - priPct;
-          const priLim = getMinMax(props.left);
-          const secLim = getMinMax(props.right);
-          if (
-            priPx < priLim.min.px ||
-            priPx > priLim.max.px ||
-            priPct < priLim.min.pct ||
-            priPct > priLim.max.pct ||
-            secPx < secLim.min.px ||
-            secPx > secLim.max.px ||
-            secPct < secLim.min.pct ||
-            secPct > secLim.max.pct
-          ) {
-            return;
-          }
-          setSizeCSS({a: `${priPct * 100}%`});
-        } else {
-          const priPx = top - HALF_BAR;
-          const priPct = priPx / height;
-          const secPx = height - priPx;
-          const secPct = 1 - priPct;
-          const priLim = getMinMax(props.top);
-          const secLim = getMinMax(props.bottom);
-          if (
-            priPx < priLim.min.px ||
-            priPx > priLim.max.px ||
-            priPct < priLim.min.pct ||
-            priPct > priLim.max.pct ||
-            secPx < secLim.min.px ||
-            secPx > secLim.max.px ||
-            secPct < secLim.min.pct ||
-            secPct > secLim.max.pct
-          ) {
-            return;
-          }
-          setSizeCSS({a: `${priPct * 100}%`});
-        }
-      }
-    },
-    [props, startMouseIdleTimer]
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  const onResize = useCallback(
+    debounce(newSize => logEndPosition(`${(newSize / props.availableSize) * 100}%`), 750),
+    [debounce, logEndPosition, props.availableSize]
   );
 
-  const elem = useRef<HTMLDivElement>(null);
-  const mouseTrackingProps = useMouseTracking<HTMLDivElement>({
-    elem,
-    onPositionChange: handlePositionChange,
+  const {
+    isHeld,
+    onDoubleClick,
+    onMouseDown: onDragStart,
+    size: containerSize,
+  } = useResizableDrawer({
+    direction: isLeftRight ? 'left' : 'down',
+    initialSize,
+    min,
+    onResize,
   });
 
-  const activeTrackingProps = isMousedown ? mouseTrackingProps : {};
+  const sizePct = `${
+    (Math.min(containerSize, max) / props.availableSize) * 100
+  }%` as `${number}%`;
+  const onMouseDown = useCallback(
+    event => {
+      setStartPosition(sizePct);
+      onDragStart(event);
+    },
+    [setStartPosition, onDragStart, sizePct]
+  );
 
-  if ('left' in props) {
+  if (isLeftRight) {
     const {left: a, right: b} = props;
 
     return (
       <SplitPanelContainer
+        className={isHeld ? 'disable-iframe-pointer' : undefined}
         orientation="columns"
-        size={sizeCSS}
-        ref={elem}
-        {...activeTrackingProps}
+        size={sizePct}
       >
-        <Panel>{getValFromSide(a, 'content') || a}</Panel>
+        <Panel>{a.content}</Panel>
         <Divider
+          isHeld={isHeld}
+          onDoubleClick={onDoubleClick}
+          onMouseDown={onMouseDown}
           slideDirection="leftright"
-          isMousedown={isMousedown}
-          onMouseDown={handleMouseDown}
         />
-        <Panel>{getValFromSide(b, 'content') || b}</Panel>
-        {isMousedown ? <HoverMouseDiv /> : null}
+        <Panel>{b}</Panel>
       </SplitPanelContainer>
     );
   }
@@ -226,25 +102,24 @@ function SplitPanel(props: Props) {
   return (
     <SplitPanelContainer
       orientation="rows"
-      size={sizeCSS}
-      ref={elem}
-      {...activeTrackingProps}
+      size={sizePct}
+      className={isHeld ? 'disable-iframe-pointer' : undefined}
     >
-      <Panel>{getValFromSide(a, 'content') || a}</Panel>
+      <Panel>{a.content}</Panel>
       <Divider
+        isHeld={isHeld}
+        onDoubleClick={onDoubleClick}
+        onMouseDown={onMouseDown}
         slideDirection="updown"
-        isMousedown={isMousedown}
-        onMouseDown={handleMouseDown}
       />
-      <Panel>{getValFromSide(b, 'content') || b}</Panel>
-      {isMousedown ? <HoverMouseDiv /> : null}
+      <Panel>{b}</Panel>
     </SplitPanelContainer>
   );
 }
 
 const SplitPanelContainer = styled('div')<{
   orientation: 'rows' | 'columns';
-  size: {a: CSSValue} | {b: CSSValue};
+  size: `${number}px` | `${number}%`;
 }>`
   width: 100%;
   height: 100%;
@@ -252,28 +127,21 @@ const SplitPanelContainer = styled('div')<{
   position: relative;
   display: grid;
   overflow: auto;
-  grid-template-${p => p.orientation}:
-    ${p => ('a' in p.size ? p.size.a : '1fr')}
-    auto
-    ${p => ('a' in p.size ? '1fr' : p.size.b)};
+  grid-template-${p => p.orientation}: ${p => p.size} auto 1fr;
+
+  &.disable-iframe-pointer iframe {
+    pointer-events: none !important;
+  }
 `;
 
 const Panel = styled('div')`
   overflow: hidden;
 `;
 
-const HoverMouseDiv = styled('div')`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-`;
-
-type DividerProps = {isMousedown: boolean; slideDirection: 'leftright' | 'updown'};
+type DividerProps = {isHeld: boolean; slideDirection: 'leftright' | 'updown'};
 const Divider = styled(
   ({
-    isMousedown: _a,
+    isHeld: _a,
     slideDirection: _b,
     ...props
   }: DividerProps & DOMAttributes<HTMLDivElement>) => (
@@ -287,7 +155,7 @@ const Divider = styled(
   height: 100%;
   width: 100%;
 
-  ${p => (p.isMousedown ? 'user-select: none;' : '')}
+  ${p => (p.isHeld ? 'user-select: none;' : '')}
 
   :hover {
     background: ${p => p.theme.hover};