aboutsummaryrefslogblamecommitdiff
path: root/haddock-api/resources/html/js-src/quick-jump.tsx
blob: 20ff8e159f9b106920e8424ec0645ee3d1dc43b2 (plain) (tree)
1
2
3
4
5



                                  










                                                                                                           





                                                  






















































                                                                                           
                     






















                                                                   
                          

                              







                                   

































                                                                                         
                                                          




























                                                                                                             
                                                                                                                










































                                                                                                 











                                                                                                                                                   















































































































































































                                                                                                                                                              
                                          
import Fuse = require('fuse.js');
import preact = require("preact");

const { h, Component } = preact;

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) {
          try {
            success(JSON.parse(xhr.responseText));
          } catch (exc) {
            error(xhr);
          }
        }
      } 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<DocItem>
}

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.25,
          caseSensitive: true,
          includeScore: true,
          tokenize: true,
          keys: [ {
                    name: "name",
                    weight: 0.7
                  },
                  {
                    name: "module",
                    weight: 0.3
                  }
                ]
        }),
        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, searchString: '' });
  }

  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) as any as FuseResult<DocItem>[];

    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) {
      const usingFileProtocol = window.location.protocol == 'file:';
      return <div id="search" class={state.isVisible ? '' : 'hidden'}>
        <div id="search-results">
          <p class="error">Failed to load file 'doc-index.json' containing definitions in this package.</p>
          {usingFileProtocol ? <p class="error">
              To use quick jump, load this page with HTTP (from a local static file web server) instead of using the <code>file://</code> protocol.
              (For security reasons, it is not possible to fetch auxiliary files using JS in a HTML page opened with <code>file://</code>.)
            </p> : []
          }
        </div>
      </div>;
    }

    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 };