aboutsummaryrefslogtreecommitdiff
path: root/haddock-api/resources/html/js-src
diff options
context:
space:
mode:
authorTim Baumann <tim@timbaumann.info>2017-09-23 22:02:01 +0200
committerAlexander Biehl <alexbiehl@gmail.com>2017-09-23 22:02:01 +0200
commite99aefb50ca63e2dbcc95841efbb53cea90151d8 (patch)
tree3ab0cebcd012122631336281dceb389dd0fd4506 /haddock-api/resources/html/js-src
parentfe4c6c7d2907a79118d836e72c0442d666091524 (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.ts173
-rw-r--r--haddock-api/resources/html/js-src/init.ts21
-rw-r--r--haddock-api/resources/html/js-src/quick-jump.tsx399
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 &#9662;</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