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