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 =>
90        head.level <= BASE_HEADING_LEVEL + this.props.maxNestingDepth! &&
91        head.title.toLowerCase() !== 'see also'
92    );
93
94    return (
95      <nav css={STYLES_SIDEBAR} data-sidebar>
96        {displayedHeadings.map(heading => {
97          const isActive = heading.slug === this.state.activeSlug;
98          return (
99            <DocumentationSidebarRightLink
100              key={heading.slug}
101              heading={heading}
102              onClick={e => this.handleLinkClick(e, heading)}
103              isActive={isActive}
104              ref={isActive ? this.activeItemRef : undefined}
105              shortenCode
106            />
107          );
108        })}
109      </nav>
110    );
111  }
112
113  /**
114   * Scrolls sidebar to keep active element always visible
115   */
116  private updateSelfScroll = () => {
117    const selfScroll = this.props.selfRef?.current?.getScrollRef().current;
118    const activeItemPos = this.activeItemRef.current?.offsetTop;
119
120    if (!selfScroll || !activeItemPos || this.slugScrollingTo) {
121      return;
122    }
123
124    const { scrollTop } = selfScroll;
125    const upperThreshold = window.innerHeight * UPPER_SCROLL_LIMIT_FACTOR;
126    const lowerThreshold = window.innerHeight * LOWER_SCROLL_LIMIT_FACTOR;
127
128    if (activeItemPos < scrollTop + upperThreshold) {
129      selfScroll.scrollTo({ behavior: 'auto', top: Math.max(0, activeItemPos - upperThreshold) });
130    } else if (activeItemPos > scrollTop + lowerThreshold) {
131      selfScroll.scrollTo({ behavior: 'auto', top: activeItemPos - lowerThreshold });
132    }
133  };
134
135  private handleLinkClick = (event: React.MouseEvent<HTMLAnchorElement>, heading: Heading) => {
136    if (!isDynamicScrollAvailable()) {
137      return;
138    }
139
140    event.preventDefault();
141    const { title, slug, ref } = heading;
142
143    // disable sidebar scrolling until we reach that slug
144    this.slugScrollingTo = slug;
145
146    this.props.contentRef?.current?.getScrollRef().current?.scrollTo({
147      behavior: 'smooth',
148      top: ref.current?.offsetTop - window.innerHeight * ACTIVE_ITEM_OFFSET_FACTOR,
149    });
150    history.replaceState(history.state, title, '#' + slug);
151  };
152}
153
154const SidebarWithHeadingManager = withHeadingManager(function SidebarWithHeadingManager({
155  reactRef,
156  ...props
157}) {
158  return <DocumentationSidebarRight {...props} ref={reactRef} />;
159}) as React.FC<Props & { reactRef: React.Ref<DocumentationSidebarRight> }>;
160
161SidebarWithHeadingManager.displayName = 'SidebarRightRefWrapper';
162
163const SidebarForwardRef = React.forwardRef<DocumentationSidebarRight, Props>((props, ref) => (
164  <SidebarWithHeadingManager {...props} reactRef={ref} />
165));
166
167export type SidebarRightComponentType = DocumentationSidebarRight;
168
169export default SidebarForwardRef;
170