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