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 | |
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')
-rw-r--r-- | haddock-api/resources/html/js-src/haddock-util.ts | 173 | ||||
-rw-r--r-- | haddock-api/resources/html/js-src/init.ts | 21 | ||||
-rw-r--r-- | haddock-api/resources/html/js-src/quick-jump.tsx | 399 |
3 files changed, 593 insertions, 0 deletions
diff --git a/haddock-api/resources/html/js-src/haddock-util.ts b/haddock-api/resources/html/js-src/haddock-util.ts new file mode 100644 index 00000000..257ceb6a --- /dev/null +++ b/haddock-api/resources/html/js-src/haddock-util.ts @@ -0,0 +1,173 @@ +// Haddock JavaScript utilities + +const rspace = /\s\s+/g, + rtrim = /^\s+|\s+$/g; + +function spaced(s: string) { return (" " + s + " ").replace(rspace, " "); } +function trim(s: string) { return s.replace(rtrim, ""); } + +function hasClass(elem: Element, value: string) { + const className = spaced(elem.className || ""); + return className.indexOf( " " + value + " " ) >= 0; +} + +function addClass(elem: Element, value: string) { + const className = spaced(elem.className || ""); + if ( className.indexOf( " " + value + " " ) < 0 ) { + elem.className = trim(className + " " + value); + } +} + +function removeClass(elem: Element, value: string) { + let className = spaced(elem.className || ""); + className = className.replace(" " + value + " ", " "); + elem.className = trim(className); +} + +function toggleClass(elem: Element, valueOn: string, valueOff: string, bool?: boolean): boolean { + if (bool == null) { bool = ! hasClass(elem, valueOn); } + if (bool) { + removeClass(elem, valueOff); + addClass(elem, valueOn); + } + else { + removeClass(elem, valueOn); + addClass(elem, valueOff); + } + return bool; +} + + +function makeClassToggle(valueOn: string, valueOff: string): (elem: Element, bool?: boolean) => boolean { + return function(elem, bool) { + return toggleClass(elem, valueOn, valueOff, bool); + } +} + +const toggleShow = makeClassToggle("show", "hide"); +const toggleCollapser = makeClassToggle("collapser", "expander"); + +function toggleSection(id: string): boolean { + const b = toggleShow(document.getElementById("section." + id) as Element); + toggleCollapser(document.getElementById("control." + id) as Element, b); + rememberCollapsed(id); + return b; +} + +// TODO: get rid of global variables +if (typeof window !== 'undefined') { + (window as any).toggleSection = toggleSection; +} + +const collapsed: { [id: string]: boolean } = {}; +function rememberCollapsed(id: string) { + if(collapsed[id]) + delete collapsed[id] + else + collapsed[id] = true; + + const sections: string[] = []; + for(let i in collapsed) { + if(collapsed.hasOwnProperty(i)) + sections.push(i); + } + // cookie specific to this page; don't use setCookie which sets path=/ + document.cookie = "collapsed=" + encodeURIComponent(sections.join('+')); +} + +export function restoreCollapsed() { + const cookie = getCookie("collapsed"); + if(!cookie) + return; + + const ids = cookie.split('+'); + for(const i in ids) + { + if(document.getElementById("section." + ids[i])) + toggleSection(ids[i]); + } +} + +function setCookie(name: string, value: string) { + document.cookie = name + "=" + encodeURIComponent(value) + ";path=/;"; +} + +function clearCookie(name: string) { + document.cookie = name + "=;path=/;expires=Thu, 01-Jan-1970 00:00:01 GMT;"; +} + +function getCookie(name: string) { + const nameEQ = name + "="; + const ca = document.cookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0)==' ') c = c.substring(1,c.length); + if (c.indexOf(nameEQ) == 0) { + return decodeURIComponent(c.substring(nameEQ.length,c.length)); + } + } + return null; +} + +function addMenuItem(html: string) { + const menu = document.getElementById("page-menu"); + if (menu && menu.firstChild) { + const btn = menu.firstChild.cloneNode(false) as Element; + btn.innerHTML = html; + menu.appendChild(btn); + } +} + +function styles(): HTMLLinkElement[] { + const es = Array.prototype.slice.call(document.getElementsByTagName("link")); + return es.filter((a: HTMLLinkElement) => a.rel.indexOf("style") != -1 && a.title); +} + +export function addStyleMenu() { + const as = styles(); + let btns = ""; + as.forEach((a) => { + btns += "<li><a href='#' onclick=\"setActiveStyleSheet('" + + a.title + "'); return false;\">" + + a.title + "</a></li>" + }); + if (as.length > 1) { + const h = "<div id='style-menu-holder'>" + + "<a href='#' onclick='styleMenu(); return false;'>Style ▾</a>" + + "<ul id='style-menu' class='hide'>" + btns + "</ul>" + + "</div>"; + addMenuItem(h); + } +} + +function setActiveStyleSheet(title: string) { + const as = styles(); + let found: null | HTMLLinkElement = null; + for(let i = 0; i < as.length; i++) { + const a = as[i]; + a.disabled = true; + // need to do this always, some browsers are edge triggered + if(a.title == title) { + found = a; + } + } + if (found) { + found.disabled = false; + setCookie("haddock-style", title); + } + else { + as[0].disabled = false; + clearCookie("haddock-style"); + } + styleMenu(false); +} + +export function resetStyle() { + const s = getCookie("haddock-style"); + if (s) setActiveStyleSheet(s); +} + +function styleMenu(show?: boolean) { + const m = document.getElementById('style-menu'); + if (m) toggleShow(m, show); +}
\ No newline at end of file diff --git a/haddock-api/resources/html/js-src/init.ts b/haddock-api/resources/html/js-src/init.ts new file mode 100644 index 00000000..0619dfc3 --- /dev/null +++ b/haddock-api/resources/html/js-src/init.ts @@ -0,0 +1,21 @@ +import * as util from "./haddock-util"; +import * as quickJump from "./quick-jump"; + +function onDomReady(callback: () => void) { + if (document.readyState === 'interactive') { + callback(); + } else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'interactive') { + callback(); + } + }); + } +} + +onDomReady(() => { + util.addStyleMenu(); + util.resetStyle(); + util.restoreCollapsed(); + quickJump.init(); +});
\ No newline at end of file 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 |