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