diff options
| author | Tim Baumann <tim@timbaumann.info> | 2017-09-23 22:02:01 +0200 | 
|---|---|---|
| committer | Alexander Biehl <alexbiehl@gmail.com> | 2017-09-23 22:02:01 +0200 | 
| commit | e99aefb50ca63e2dbcc95841efbb53cea90151d8 (patch) | |
| tree | 3ab0cebcd012122631336281dceb389dd0fd4506 /haddock-api/resources/html/js-src/quick-jump.tsx | |
| parent | fe4c6c7d2907a79118d836e72c0442d666091524 (diff) | |
Add compile step that bundles and compresses JS files (#684)
* Add compile step that bundles and compresses JS files
Also, manage dependencies on third-party JS libraries using NPM.
* Compile JS from TypeScript
* Enable 'noImplicitAny' in TypeScript
* QuickJump: use JSX syntax
* Generate source maps from TypeScript for easier debugging
* TypeScript: more accurate type
* Separate quick jump css file from ocean theme
Diffstat (limited to 'haddock-api/resources/html/js-src/quick-jump.tsx')
| -rw-r--r-- | haddock-api/resources/html/js-src/quick-jump.tsx | 399 | 
1 files changed, 399 insertions, 0 deletions
| diff --git a/haddock-api/resources/html/js-src/quick-jump.tsx b/haddock-api/resources/html/js-src/quick-jump.tsx new file mode 100644 index 00000000..a2bcdb64 --- /dev/null +++ b/haddock-api/resources/html/js-src/quick-jump.tsx @@ -0,0 +1,399 @@ +import Fuse = require('fuse.js'); +import preact = require("preact"); + +const { h, Component } = preact; + +declare interface ObjectConstructor { +  assign(target: any, ...sources: any[]): any; +} + +type DocItem = { +  display_html: string +  name: string +  module: string +  link: string +} + +function loadJSON(path: string, success: (json: DocItem[]) => void, error: (xhr: XMLHttpRequest) => void) { +  const xhr = new XMLHttpRequest(); +  xhr.onreadystatechange = () => { +    if (xhr.readyState === XMLHttpRequest.DONE) { +      if (xhr.status === 200) { +        if (success) +          success(JSON.parse(xhr.responseText)); +      } else { +        if (error) { error(xhr); } +      } +    } +  }; +  xhr.open("GET", path, true); +  xhr.send(); +} + +// -------------------------------------------------------------------------- // + +class PageMenuButton extends Component<any, any> { + +  render(props: { title: string, onClick: () => void }) { +    function onClick(e: Event) { +      e.preventDefault(); +      props.onClick(); +    } +    return <li><a href="#" onClick={onClick}>{props.title}</a></li>; +  } + +} + +function addSearchPageMenuButton(action: () => void) { +  const pageMenu = document.querySelector('#page-menu') as HTMLUListElement; +  const dummy = document.createElement('li'); +  pageMenu.insertBefore(dummy, pageMenu.firstChild); +  preact.render(<PageMenuButton onClick={action} title="Quick Jump" />, pageMenu, dummy); +} + +// -------------------------------------------------------------------------- // + +function take<T>(n: number, arr: T[]) { +  if (arr.length <= n) { return arr; } +  return arr.slice(0, n); +} + +type FuseResult<T> = { +  score: number +  item: T +} + +type ResultsInModule = { module: string, totalScore: number, items: FuseResult<DocItem>[] } + +type QuickJumpProps = { +  baseUrl: string +  showHideTrigger: (action: () => void) => void +} + +type QuickJumpState = { +  searchString: string +  isVisible: boolean +  expanded: { [moduleName: string]: true } +  activeLinkIndex: number +  moduleResults: ResultsInModule[] +  failedLoading?: boolean +  fuse: Fuse +} + +class QuickJump extends Component<QuickJumpProps, QuickJumpState> { + +  private linkIndex: number = 0; +  private focusPlease: boolean = false; +  private navigatedByKeyboard: boolean = false; +  private activeLink: undefined | HTMLAnchorElement; +  private activeLinkAction: undefined | (() => void); + +  private input: undefined | HTMLInputElement; +  private searchResults: undefined | Element; + +  componentWillMount() { +    this.setState({ +      searchString: '', +      isVisible: false, +      expanded: {}, +      activeLinkIndex: -1, +      moduleResults: [] +    }); +    loadJSON(this.props.baseUrl + "/doc-index.json", (data) => { +      this.setState({ +        fuse: new Fuse(data, { +          threshold: 0.4, +          caseSensitive: true, +          includeScore: true, +          tokenize: true, +          keys: ["name", "module"] +        }), +        moduleResults: [] +      }); +    }, (err) => { +      if (console) { +        console.error("could not load 'doc-index.json' for searching", err); +      } +      this.setState({ failedLoading: true }); +    }); + +    document.addEventListener('mousedown', this.hide.bind(this)); + +    document.addEventListener('keydown', (e) => { +      if (this.state.isVisible) { +        if (e.key === 'Escape') { +          this.hide(); +        } else if (e.key === 'ArrowUp' || (e.key === 'k' && e.ctrlKey)) { +          e.preventDefault(); +          this.navigateLinks(-1); +        } else if (e.key === 'ArrowDown' || (e.key === 'j' && e.ctrlKey)) { +          e.preventDefault(); +          this.navigateLinks(+1); +        } else if (e.key === 'Enter' && this.state.activeLinkIndex >= 0) { +          this.followActiveLink(); +        } +      } + +      if (e.key === 's' && (e.target as HTMLElement).tagName.toLowerCase() !== 'input') { +        e.preventDefault(); +        this.show(); +      } +    }) +  } + +  hide() { +    this.setState({ isVisible: false }); +  } + +  show() { +    if (!this.state.isVisible) { +      this.focusPlease = true; +      this.setState({ isVisible: true, activeLinkIndex: -1 }); +    } +  } + +  toggleVisibility() { +    if (this.state.isVisible) { +      this.hide(); +    } else { +      this.show(); +    } +  } + +  navigateLinks(change: number) { +    const newActiveLinkIndex = Math.max(-1, Math.min(this.linkIndex-1, this.state.activeLinkIndex + change)); +    this.navigatedByKeyboard = true; +    this.setState({ activeLinkIndex: newActiveLinkIndex }); +  } + +  followActiveLink() { +    if (!this.activeLinkAction) { return; } +    this.activeLinkAction(); +  } + +  updateResults() { +    const searchString = (this.input && this.input.value) || ''; +    const results: FuseResult<DocItem>[] = this.state.fuse.search(searchString); + +    const resultsByModule: { [name: string]: FuseResult<DocItem>[] } = {}; + +    results.forEach((result) => { +      const moduleName = result.item.module; +      const resultsInModule = resultsByModule[moduleName] || (resultsByModule[moduleName] = []); +      resultsInModule.push(result); +    }); + +    const moduleResults: ResultsInModule[] = []; +    for (const moduleName in resultsByModule) { +      const items = resultsByModule[moduleName]; +      let sumOfInverseScores = 0; +      items.forEach((item) => { sumOfInverseScores += 1/item.score; }); +      moduleResults.push({ module: moduleName, totalScore: 1/sumOfInverseScores, items: items }); +    } + +    moduleResults.sort((a, b) => a.totalScore - b.totalScore); + +    this.setState({ searchString: searchString, isVisible: true, moduleResults: moduleResults }); +  } + +  componentDidUpdate() { +    if (this.searchResults && this.activeLink && this.navigatedByKeyboard) { +      const rect = this.activeLink.getClientRects()[0]; +      const searchResultsTop = this.searchResults.getClientRects()[0].top; +      if (rect.bottom > window.innerHeight) { +        this.searchResults.scrollTop += rect.bottom - window.innerHeight + 80; +      } else if (rect.top < searchResultsTop) { +        this.searchResults.scrollTop -= searchResultsTop - rect.top + 80; +      } +    } +    if (this.focusPlease && this.input) { +      this.input.focus(); +    } +    this.navigatedByKeyboard = false; +    this.focusPlease = false; +  } + +  componentDidMount() { +    this.props.showHideTrigger(this.toggleVisibility.bind(this)); +  } + +  render(props: any, state: QuickJumpState) { +    if (state.failedLoading) { return null; } + +    this.linkIndex = 0; + +    const stopPropagation = (e: Event) => { e.stopPropagation(); }; + +    const onMouseOver = (e: MouseEvent) => { +      let target: null | Element = e.target as Element; +      while (target && typeof target.getAttribute === 'function') { +        const linkIndexString = target.getAttribute('data-link-index'); +        if (typeof linkIndexString == 'string') { +          const linkIndex = parseInt(linkIndexString, 10); +          this.setState({ activeLinkIndex: linkIndex }); +          break; +        } +        target = target.parentNode as null | Element; +      } +    }; + +    const items = take(10, state.moduleResults).map((r) => this.renderResultsInModule(r)); + +    return <div id="search" class={state.isVisible ? '' : 'hidden'}> +      <div id="search-form" onMouseDown={stopPropagation}> +        <input +          placeholder="Search in package by name" +          ref={(input) => { this.input = input as undefined | HTMLInputElement; }} +          onFocus={this.show.bind(this)} +          onClick={this.show.bind(this)} +          onInput={this.updateResults.bind(this)} +        /> +      </div> +      <div id="search-results" ref={(el) => { this.searchResults = el; }} +        onMouseDown={stopPropagation} onMouseOver={onMouseOver}> +        {state.searchString === '' +          ? [<IntroMsg />, <KeyboardShortcuts />] +          : items.length == 0 +            ? <NoResultsMsg searchString={state.searchString} /> +            : <ul>{items}</ul>} +      </div> +    </div>; +  } + +  renderResultsInModule(resultsInModule: ResultsInModule): JSX.Element { +    const items = resultsInModule.items; +    const moduleName = resultsInModule.module; +    const showAll = this.state.expanded[moduleName] || items.length <= 10; +    const visibleItems = showAll ? items : take(8, items); + +    const expand = () => { +      const newExpanded = Object.assign({}, this.state.expanded); +      newExpanded[moduleName] = true; +      this.setState({ expanded: newExpanded }); +    }; + +    const renderItem = (item: DocItem) => { +      return <li class="search-result"> +        {this.navigationLink(this.props.baseUrl + "/" + item.link, {}, +          <DocHtml html={item.display_html} /> +        )} +      </li>; +    }; + +    return <li class="search-module"> +      <h4>{moduleName}</h4> +      <ul> +        {visibleItems.map((item) => renderItem(item.item))} +        {showAll +          ? [] +          : <li class="more-results"> +              {this.actionLink(expand, {}, "show " + (items.length - visibleItems.length) + " more results from this module")} +            </li>} +      </ul> +    </li>; +  } + +  navigationLink(href: string, attrs: JSX.HTMLAttributes&JSX.SVGAttributes&{[propName: string]: any}, ...children: (JSX.Element|JSX.Element[]|string)[]) { +    const fullAttrs = Object.assign({ href: href, onClick: this.hide.bind(this) }, attrs); +    const action = () => { window.location.href = href; this.hide(); }; +    return this.menuLink(fullAttrs, action, ...children); +  } + +  actionLink(callback: () => void, attrs: JSX.HTMLAttributes&JSX.SVGAttributes&{[propName: string]: any}, ...children: (JSX.Element|JSX.Element[]|string)[]) { +    const onClick = (e: Event) => { e.preventDefault(); callback(); }; +    const fullAttrs = Object.assign({ href: '#', onClick: onClick }, attrs); +    return this.menuLink(fullAttrs, callback, ...children); +  } + +  menuLink(attrs: JSX.HTMLAttributes&JSX.SVGAttributes&{[propName: string]: any}, action: () => void, ...children: (JSX.Element|JSX.Element[]|string)[]) { +    const linkIndex = this.linkIndex; +    if (linkIndex === this.state.activeLinkIndex) { +      attrs['class'] = (attrs['class'] ? attrs['class'] + ' ' : '') + 'active-link'; +      attrs.ref = (link?: Element) => { if (link) this.activeLink = link as HTMLAnchorElement; }; +      this.activeLinkAction = action; +    } +    const newAttrs = Object.assign({ 'data-link-index': linkIndex }, attrs); +    this.linkIndex += 1; +    return h('a', newAttrs, ...children); +  } + +} + +class DocHtml extends Component<{ html: string }, {}> { + +  shouldComponentUpdate(newProps: { html: string }) { +    return this.props.html !== newProps.html; +  } + +  render(props: { html: string }) { +    return <div dangerouslySetInnerHTML={{__html: props.html}} />; +  } + +}; + +function KeyboardShortcuts() { +  return <table class="keyboard-shortcuts"> +    <tr> +      <th>Key</th> +      <th>Shortcut</th> +    </tr> +    <tr> +      <td><span class="key">s</span></td> +      <td>Open this search box</td> +    </tr> +    <tr> +      <td><span class="key">esc</span></td> +      <td>Close this search box</td> +    </tr> +    <tr> +      <td> +        <span class="key">↓</span>, +        <span class="key">ctrl</span> + <span class="key">j</span> +      </td> +      <td>Move down in search results</td> +    </tr> +    <tr> +      <td> +        <span class="key">↑</span>, +        <span class="key">ctrl</span> + <span class="key">k</span> +      </td> +      <td>Move up in search results</td> +    </tr> +    <tr> +      <td><span class="key">↵</span></td> +      <td>Go to active search result</td> +    </tr> +  </table>; +} + +function IntroMsg() { +  return <p>You can find any exported type, constructor, class, function or pattern defined in this package by (approximate) name.</p>; +} + +function NoResultsMsg(props: { searchString: string }) { +  const messages = [ +    <p> +      Your search for '{props.searchString}' produced the following list of results: <code>[]</code>. +    </p>, +    <p> +      <code>Nothing</code> matches your query for '{props.searchString}'. +    </p>, +    <p> +      <code> +        Left "no matches for '{props.searchString}'" :: Either String (NonEmpty SearchResult) +      </code> +    </p> +  ]; + +  return messages[(props.searchString || 'a').charCodeAt(0) % messages.length]; +} + +export function init(docBaseUrl?: string, showHide?: (action: () => void) => void) { +  preact.render( +    <QuickJump baseUrl={docBaseUrl || "."} showHideTrigger={showHide || addSearchPageMenuButton} />, +    document.body +  ); +} + +// export to global object +(window as any).quickNav = { init: init };
\ No newline at end of file | 
