import { IHeadingNode , IPageData , IPageNode , isPageNode , ITsDocBase , linkify } from "@documentalist/client" ; import classNames from "classnames" ; import * as React from "react" ; import { Classes , Drawer , FocusStyleManager , HotkeysTarget2 , Props , Utils } from "@blueprintjs/core" ; import { DocumentationContextTypes , hasTypescriptData , IDocsData , IDocumentationContext } from "../common/context" ; import { eachLayoutNode } from "../common/utils" ; import { TagRendererMap , TypescriptExample } from "../tags" ; import { renderBlock } from "./block" ; import { NavButton } from "./navButton" ; import { Navigator } from "./navigator" ; import { NavMenu } from "./navMenu" ; import { NavMenuItemProps } from "./navMenuItem" ; import { Page } from "./page" ; import { addScrollbarStyle } from "./scrollbar" ; import { ApiLink } from "./typescript/apiLink" ; export interface IDocumentationProps extends Props { /**
     * An element to place above the documentation, along the top of the viewport.
     * For best results, use a `Banner` from this package.
     */ banner ? : JSX . Element ; /**
     * Default page to render in the absence of a hash route.
     */ defaultPageId : string ; /**
     * All the docs data from Documentalist.
     * This theme requires the Markdown plugin, and optionally supports Typescript and KSS data.
     */ docs : IDocsData ; /**
     * Elements to render on the bottom of the sidebar, below the nav menu.
     * This typically contains copyright information.
     */ footer ? : React . ReactNode ; /**
     * Elements to render on the top of the sidebar, above the search box.
     * This typically contains logo, title and navigation links.
     * Use `.docs-nav-title` on an element for proper padding relative to other sidebar elements.
     */ header : React . ReactNode ; /**
     * Callback invoked to determine if given nav node should *not* be
     * searchable in the navigator. Returning `true` will exclude the item from
     * the navigator search results.
     */ navigatorExclude ? : ( node : IPageNode | IHeadingNode ) => boolean ; /**
     * Callback invoked whenever the component props or state change (specifically,
     * called in `componentDidMount` and `componentDidUpdate`).
     * Use it to run non-React code on the newly rendered sections.
     */ onComponentUpdate ? : ( pageId : string ) => void ; default renderViewSourceLinkText ? : ( entry : ITsDocBase ) => React . ReactNode ; /**
     * Callback invoked to render the clickable nav menu items. (Nested menu structure is handled by the library.)
     * The default implementation renders a `NavMenuItem` element, which is exported from this package.
     */ renderNavMenuItem ? : ( props : NavMenuItemProps ) => JSX . Element ; /**
     * Callback invoked to render actions for a documentation page.
     * Actions appear in an element in the upper-right corner of the page.
     */ renderPageActions ? : ( page : IPageData ) => React . ReactNode ; default scrollParent ? : HTMLElement ; /** Tag renderer functions. Unknown tags will log console errors. */ tagRenderers : TagRendererMap ; } export interface IDocumentationState { activeApiMember : string ; activePageId : string ; activeSectionId : string ; isApiBrowserOpen : boolean ; isNavigatorOpen : boolean ; } export class Documentation extends React . PureComponent < IDocumentationProps , IDocumentationState > { public static childContextTypes = DocumentationContextTypes ; /** Map of section route to containing page reference. */ private routeToPage : { [ route ] : string ; } ; private contentElement : HTMLElement ; private navElement : HTMLElement ; private refHandlers = { content : ( ref ) => ( this . contentElement = ref ) , nav : ( ref ) => ( this . navElement = ref ) , } ; public constructor ( props ) { super ( props ) ; this . state = { activeApiMember : "" , activePageId : props . defaultPageId , activeSectionId : props . defaultPageId , isApiBrowserOpen : false , isNavigatorOpen : false , } ; this . routeToPage = { } ; eachLayoutNode ( this . props . docs . nav , ( node , parents ) => { const { reference } = isPageNode ( node ) ? node : parents [ 0 ] ; this . routeToPage [ node . route ] = reference ; } ) ; } public getChildContext ( ) { const { docs , renderViewSourceLinkText } = this . props ; return { getDocsData : ( ) => docs , renderBlock : block => renderBlock ( block , this . props . tagRenderers ) , renderType : hasTypescriptData ( docs ) ? type => linkify ( type , docs . typescript , ( name , _d , idx ) => < ApiLink key = { `${ name }-${ idx }` } name = { name } / > ) : type => type , renderViewSourceLinkText : Utils . isFunction ( renderViewSourceLinkText ) ? renderViewSourceLinkText : ( ) => "View source" , showApiDocs : this . handleApiBrowserOpen , } ; } public render ( ) { const { activeApiMember , activePageId , activeSectionId , isApiBrowserOpen } = this . state ; const { nav , pages } = this . props . docs ; const rootClasses = classNames ( "docs-root" , { "docs-examples-only" : location . search === "?examples" } , this . props . className ) ; const apiClasses = classNames ( "docs-api-drawer" , this . props . className ) ; return ( < HotkeysTarget2 hotkeys = { [ { combo : "shift+s" , global : true , group : "Navigation (global)" , label : "Open navigator" , onKeyDown : this . handleOpenNavigator , preventDefault : true , } , { combo : "[" , global : true , group : "Navigation (global)" , label : "Previous section" , onKeyDown : this . handlePreviousSection , } , { combo : "]" , global : true , group : "Navigation (global)" , label : "Next section" , onKeyDown : this . handleNextSection , } , ] } > < div className = { rootClasses } > { this . props . banner } < div className = "docs-app" > < div className = "docs-nav-wrapper" > < div className = "docs-nav" ref = { this . refHandlers . nav } > { this . props . header } < div className = "docs-nav-divider" / > < NavButton icon = "search" hotkey = "shift + s" text = "Search..." onClick = { this . handleOpenNavigator } / > < div className = "docs-nav-divider" / > < NavMenu activePageId = { activePageId } activeSectionId = { activeSectionId } items = { nav } level = { 0 } onItemClick = { this . handleNavigation } renderNavMenuItem = { this . props . renderNavMenuItem } / > { this . props . footer } < /div> < /div> < main className = { classNames ( "docs-content-wrapper" , Classes . FILL ) } ref = { this . refHandlers . content } role = "main" > < Page page = { pages [ activePageId ] } renderActions = { this . props . renderPageActions } tagRenderers = { this . props . tagRenderers } / > < /main> < /div> < Drawer className = { apiClasses } isOpen = { isApiBrowserOpen } onClose = { this . handleApiBrowserClose } > < TypescriptExample tag = "typescript" value = { activeApiMember } / > < /Drawer> < Navigator isOpen = { this . state . isNavigatorOpen } items = { nav } itemExclude = { this . props . navigatorExclude } onClose = { this . handleCloseNavigator } / > < /div> < /HotkeysTarget2>); } public componentWillMount ( ) { addScrollbarStyle ( ) ; this . updateHash ( ) ; } public componentDidMount ( ) { FocusStyleManager . onlyShowFocusOnTabs ( ) ; this . scrollToActiveSection ( ) ; this . props . onComponentUpdate ?. ( this . state . activePageId ) ; window . addEventListener ( "hashchange" , this . handleHashChange ) ; document . addEventListener ( "scroll" , this . handleScroll ) ; requestAnimationFrame ( ( ) => this . maybeScrollToActivePageMenuItem ( ) ) ; } public componentWillUnmount ( ) { window . removeEventListener ( "hashchange" , this . handleHashChange ) ; document . removeEventListener ( "scroll" , this . handleScroll ) ; } public componentDidUpdate ( _prevProps , prevState ) { const { activePageId } = this . state ; if ( prevState . activePageId !== activePageId ) { this . scrollToActiveSection ( ) ; this . maybeScrollToActivePageMenuItem ( ) ; } this . props . onComponentUpdate ?. ( activePageId ) ; } private updateHash ( ) { const sectionId = location . hash . slice ( 1 ) ; this . handleNavigation ( sectionId === "" ? this . props . defaultPageId : sectionId ) ; } private handleHashChange = ( ) => { if ( location . hostname . indexOf ( "blueprint" ) !== - 1 ) { ( window as any ) . ga ( "send" , "pageview" , { page : location . pathname + location . search + location . hash , } ) ; } this . updateHash ( ) ; } ; private handleCloseNavigator = ( ) => this . setState ( { isNavigatorOpen : false } ) ; private handleOpenNavigator = ( ) => this . setState ( { isNavigatorOpen : true } ) ; private handleNavigation = ( activeSectionId ) => { const activePageId = this . routeToPage [ activeSectionId ] ; if ( activeSectionId !== undefined && activePageId !== undefined ) { this . setState ( { activePageId , activeSectionId , isNavigatorOpen : false } ) ; this . scrollToActiveSection ( ) ; } } ; private handleNextSection = ( ) => this . shiftSection ( 1 ) ; private handlePreviousSection = ( ) => this . shiftSection ( - 1 ) ; private handleScroll = ( ) => { const activeSectionId = getScrolledReference ( 100 , this . props . scrollParent ) ; if ( activeSectionId == null ) { return ; } this . setState ( { activeSectionId } ) ; } ; private maybeScrollToActivePageMenuItem ( ) { const { activeSectionId } = this . state ; const navItemElement = this . navElement . querySelector ( `a[href="#${ activeSectionId }"]` ) as HTMLElement ; const scrollOffset = navItemElement . offsetTop - this . navElement . scrollTop ; if ( scrollOffset < 0 || scrollOffset > this . navElement . offsetHeight ) { this . navElement . scrollTop = navItemElement . offsetTop - navItemElement . offsetHeight * 2 ; } } private scrollToActiveSection ( ) { if ( this . contentElement != null ) { scrollToReference ( this . state . activeSectionId , this . props . scrollParent ) ; } } private shiftSection ( direction ) { const currentSectionId = location . hash . slice ( 1 ) ; const sections = Object . keys ( this . routeToPage ) ; const index = sections . indexOf ( currentSectionId ) ; const newIndex = index === - 1 ? 0 : ( index + direction + sections . length ) % sections . length ; location . hash = sections [ newIndex ] ; } private handleApiBrowserOpen = ( activeApiMember ) => this . setState ( { activeApiMember , isApiBrowserOpen : true } ) ; private handleApiBrowserClose = ( ) => this . setState ( { isApiBrowserOpen : false } ) ; } /** Shorthand for element.querySelector() + cast to HTMLElement */ function queryHTMLElement ( parent , selector ) { return parent . querySelector ( selector ) as HTMLElement ; } /**
 * Returns the reference of the closest section within `offset` pixels of the top of the viewport.
 */ function getScrolledReference ( offset , scrollContainer = document . documentElement ) { const headings = Array . from ( scrollContainer . querySelectorAll ( ".docs-title" ) ) ; while ( headings . length > 0 ) { const element = headings . pop ( ) as HTMLElement ; if ( element . offsetTop < scrollContainer . scrollTop + offset ) { return element . querySelector ( "[data-route]" ) . getAttribute ( "data-route" ) ; } } return undefined ; } /**
 * Scroll the scroll container such that the reference heading appears at the top of the viewport.
 */ function scrollToReference ( reference , scrollContainer = document . documentElement ) { requestAnimationFrame ( ( ) => { const headingAnchor = queryHTMLElement ( scrollContainer , `a[data-route="${ reference }"]` ) ; if ( headingAnchor != null && headingAnchor . parentElement != null ) { const scrollOffset = headingAnchor . parentElement ! . offsetTop + headingAnchor . offsetTop ; scrollContainer . scrollTop = scrollOffset ; } } ) ; }