1import { css } from '@emotion/react';
2import * as React from 'react';
3
4import { BASE_HEADING_LEVEL, Heading, HeadingManager } from '../common/headingManager';
5import DocumentationSidebarRightLink from './DocumentationSidebarRightLink';
6
7import withHeadingManager from '~/components/page-higher-order/withHeadingManager';
8import * as Constants from '~/constants/theme';
9
10const STYLES_SIDEBAR = css`
11  padding: 20px 24px 24px 24px;
12  width: 280px;
13
14  @media screen and (max-width: ${Constants.breakpoints.mobile}) {
15    width: 100%;
16  }
17`;
18
19const UPPER_SCROLL_LIMIT_FACTOR = 1 / 4;
20const LOWER_SCROLL_LIMIT_FACTOR = 3 / 4;
21
22const ACTIVE_ITEM_OFFSET_FACTOR = 1 / 6;
23
24const isDynamicScrollAvailable = () => {
25  if (!history?.replaceState) {
26    return false;
27  }
28
29  if (window.matchMedia('(prefers-reduced-motion)').matches) {
30    return false;
31  }
32
33  return true;
34};
35
36type Props = {
37  maxNestingDepth?: number;
38  selfRef?: React.RefObject<any>;
39  contentRef?: React.RefObject<any>;
40};
41
42type PropsWithHM = Props & { headingManager: HeadingManager };
43
44type State = {
45  activeSlug: string | null;
46};
47
48class DocumentationSidebarRight extends React.Component<PropsWithHM, State> {
49  static defaultProps = {
50    maxNestingDepth: 4,
51  };
52
53  state = {
54    activeSlug: null,
55  };
56
57  private slugScrollingTo: string | null = null;
58  private activeItemRef = React.createRef<HTMLAnchorElement>();
59
60  public handleContentScroll(contentScrollPosition: number) {
61    const { headings } = this.props.headingManager;
62
63    for (const { ref, slug } of headings) {
64      if (!ref || !ref.current) {
65        continue;
66      }
67      if (
68        ref.current.offsetTop >=
69          contentScrollPosition + window.innerHeight * ACTIVE_ITEM_OFFSET_FACTOR &&
70        ref.current.offsetTop <= contentScrollPosition + window.innerHeight / 2
71      ) {
72        if (slug !== this.state.activeSlug) {
73          // we can enable scrolling again
74          if (slug === this.slugScrollingTo) {
75            this.slugScrollingTo = null;
76          }
77          this.setState({ activeSlug: slug }, this.updateSelfScroll);
78        }
79        return;
80      }
81    }
82  }
83
84  render() {
85    const { headings } = this.props.headingManager;
86
87    //filter out headings nested too much
88    const displayedHeadings = headings.filter(
89      head => head.level <= BASE_HEADING_LEVEL + this.props.maxNestingDepth!
90    );
91
92    return (
93      <nav css={STYLES_SIDEBAR} data-sidebar>
94        {displayedHeadings.map(heading => {
95          const isActive = heading.slug === this.state.activeSlug;
96          return (
97            <DocumentationSidebarRightLink
98              key={heading.slug}
99              heading={heading}
100              onClick={e => this.handleLinkClick(e, heading)}
101              isActive={isActive}
102              ref={isActive ? this.activeItemRef : undefined}
103              shortenCode
104            />
105          );
106        })}
107      </nav>
108    );
109  }
110
111  /**
112   * Scrolls sidebar to keep active element always visible
113   */
114  private updateSelfScroll = () => {
115    const selfScroll = this.props.selfRef?.current?.getScrollRef().current;
116    const activeItemPos = this.activeItemRef.current?.offsetTop;
117
118    if (!selfScroll || !activeItemPos || this.slugScrollingTo) {
119      return;
120    }
121
122    const { scrollTop } = selfScroll;
123    const upperThreshold = window.innerHeight * UPPER_SCROLL_LIMIT_FACTOR;
124    const lowerThreshold = window.innerHeight * LOWER_SCROLL_LIMIT_FACTOR;
125
126    if (activeItemPos < scrollTop + upperThreshold) {
127      selfScroll.scrollTo({ behavior: 'auto', top: Math.max(0, activeItemPos - upperThreshold) });
128    } else if (activeItemPos > scrollTop + lowerThreshold) {
129      selfScroll.scrollTo({ behavior: 'auto', top: activeItemPos - lowerThreshold });
130    }
131  };
132
133  private handleLinkClick = (event: React.MouseEvent<HTMLAnchorElement>, heading: Heading) => {
134    if (!isDynamicScrollAvailable()) {
135      return;
136    }
137
138    event.preventDefault();
139    const { title, slug, ref } = heading;
140
141    // disable sidebar scrolling until we reach that slug
142    this.slugScrollingTo = slug;
143
144    this.props.contentRef?.current?.getScrollRef().current?.scrollTo({
145      behavior: 'smooth',
146      top: ref.current?.offsetTop - window.innerHeight * ACTIVE_ITEM_OFFSET_FACTOR,
147    });
148    history.replaceState(history.state, title, '#' + slug);
149  };
150}
151
152const SidebarWithHeadingManager = withHeadingManager(function SidebarWithHeadingManager({
153  reactRef,
154  ...props
155}) {
156  return <DocumentationSidebarRight {...props} ref={reactRef} />;
157}) as React.FC<Props & { reactRef: React.Ref<DocumentationSidebarRight> }>;
158
159SidebarWithHeadingManager.displayName = 'SidebarRightRefWrapper';
160
161const SidebarForwardRef = React.forwardRef<DocumentationSidebarRight, Props>((props, ref) => (
162  <SidebarWithHeadingManager {...props} reactRef={ref} />
163));
164
165export type SidebarRightComponentType = DocumentationSidebarRight;
166
167export default SidebarForwardRef;
168