// noinspection DuplicatedCode 'use strict'; // Whether the current browser is WebKit. let isWebKitBrowser; // The lazily initialized worker for running searches, if any. let searchWorker = null; // Tells whether non-worker search is ready for use. // Only relevant if we determined that we can't use a worker. let nonWorkerSearchInitialized = false; // The search div containing search input and search results. let searchElement; // The search input element. let searchInput; // The package name associated with the current page, if any. let packageName; let packageVersion; // The module name associated with the current page, if any. let moduleName; // The class name associated with the current page, if any. let className; // Prefix to turn a site-relative URL into a page-relative URL. // One of "", "../", "../../", etc. let rootUrlPrefix; // Prefix to turn a package-relative URL into a page-relative URL. // One of "", "../", "../../", etc. let packageUrlPrefix; // The search result currently selected in the search results list. let selectedSearchResult = null; // Initializes the UI. // Wrapped in a function to avoid execution in tests. // noinspection JSUnusedGlobalSymbols function onLoad() { isWebKitBrowser = navigator.userAgent.indexOf('AppleWebKit') !== -1; searchElement = document.getElementById('search'); searchInput = document.getElementById('search-input'); packageName = searchInput.dataset.packageName || null; packageVersion = searchInput.dataset.packageVersion || null; moduleName = searchInput.dataset.moduleName || null; className = searchInput.dataset.className || null; rootUrlPrefix = searchInput.dataset.rootUrlPrefix; packageUrlPrefix = searchInput.dataset.packageUrlPrefix; initExpandTargetMemberDocs(); initNavigateToMemberPage(); initToggleMemberDocs(); initToggleInheritedMembers(); initCopyModuleUriToClipboard(); initSearchUi(); } // If page URL contains a fragment, expand the target member's docs. // Handled in JS rather than CSS so that target member can still be manually collapsed. function initExpandTargetMemberDocs() { const expandTargetDocs = () => { const hash = window.location.hash; if (hash.length === 0) return; const target = document.getElementById(hash.substring(1)); if (!target) return; const member = target.nextElementSibling; if (!member || !member.classList.contains('with-expandable-docs')) return; expandMemberDocs(member); } window.addEventListener('hashchange', expandTargetDocs); expandTargetDocs(); } // For members that have their own page, navigate to that page when the member's box is clicked. function initNavigateToMemberPage() { const elements = document.getElementsByClassName('with-page-link'); for (const element of elements) { const memberLink = element.getElementsByClassName('name-decl')[0]; // check if this is actually a link // (it isn't if the generator couldn't resolve the link target) if (memberLink.tagName === 'A') { element.addEventListener('click', (e) => { // don't act if user clicked a link if (e.target !== null && e.target.closest('a') !== null) return; // don't act if user clicked to select some text if (window.getSelection().toString()) return; memberLink.click(); }); } } } // Expands and collapses member docs. function initToggleMemberDocs() { const elements = document.getElementsByClassName('with-expandable-docs'); for (const element of elements) { element.addEventListener('click', (e) => { // don't act if user clicked a link if (e.target !== null && e.target.closest('a') !== null) return; // don't act if user clicked to select some text if (window.getSelection().toString()) return; toggleMemberDocs(element); }); } } // Shows and hides inherited members. function initToggleInheritedMembers() { const memberGroups = document.getElementsByClassName('member-group'); for (const group of memberGroups) { const button = group.getElementsByClassName('toggle-inherited-members-link')[0]; if (button !== undefined) { const members = group.getElementsByClassName('inherited'); button.addEventListener('click', () => toggleInheritedMembers(button, members)); } } } // Copies the module URI optionally displayed on a module page to the clipboard. function initCopyModuleUriToClipboard() { const copyUriButtons = document.getElementsByClassName('copy-uri-button'); for (const button of copyUriButtons) { const moduleUri = button.previousElementSibling; button.addEventListener('click', e => { e.stopPropagation(); const range = document.createRange(); range.selectNodeContents(moduleUri); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); try { document.execCommand('copy'); } catch (e) { } finally { selection.removeAllRanges(); } }); } } // Expands or collapses member docs. function toggleMemberDocs(memberElem) { const comments = memberElem.getElementsByClassName('expandable'); const icon = memberElem.getElementsByClassName('expandable-docs-icon')[0]; const isCollapsed = icon.textContent === 'expand_more'; if (isCollapsed) { for (const comment of comments) expandElement(comment); icon.textContent = 'expand_less'; } else { for (const comment of comments) collapseElement(comment); icon.textContent = 'expand_more'; } } // Expands member docs unless they are already expanded. function expandMemberDocs(memberElem) { const icon = memberElem.getElementsByClassName('expandable-docs-icon')[0]; const isCollapsed = icon.textContent === 'expand_more'; if (!isCollapsed) return; const comments = memberElem.getElementsByClassName('expandable'); for (const comment of comments) expandElement(comment); icon.textContent = 'expand_less'; } // Shows and hides inherited members. function toggleInheritedMembers(button, members) { const isCollapsed = button.textContent === 'show inherited'; if (isCollapsed) { for (const member of members) expandElement(member); button.textContent = 'hide inherited'; } else { for (const member of members) collapseElement(member); button.textContent = 'show inherited' } } // Expands an element. // Done in two steps to make transition work (can't transition from 'hidden'). // For some reason (likely related to removing 'hidden') the transition isn't animated in FF. // When using timeout() instead of requestAnimationFrame() // there is *some* animation in FF but still doesn't look right. function expandElement(element) { element.classList.remove('hidden'); requestAnimationFrame(() => { element.classList.remove('collapsed'); }); } // Collapses an element. // Done in two steps to make transition work (can't transition to 'hidden'). function collapseElement(element) { element.classList.add('collapsed'); const listener = () => { element.removeEventListener('transitionend', listener); element.classList.add('hidden'); }; element.addEventListener('transitionend', listener); } // Initializes the search UI and sets up delayed initialization of the search engine. function initSearchUi() { // initialize search engine the first time that search input receives focus const onFocus = () => { searchInput.removeEventListener('focus', onFocus); initSearchWorker(); }; searchInput.addEventListener('focus', onFocus); // clear search when search input loses focus, // except if this happens due to a search result being clicked, // in which case clearSearch() will be called by the link's click handler, // and calling it here would prevent the click handler from firing searchInput.addEventListener('focusout', () => { if (document.querySelector('#search-results:hover') === null) clearSearch(); }); // trigger search when user hasn't typed in a while let timeoutId = null; // Using anything other than `overflow: visible` for `#search-results` // slows down painting significantly in WebKit browsers (at least Safari/Mac). // Compensate by using a higher search delay, which is less annoying than a blocking UI. const delay = isWebKitBrowser ? 200 : 100; searchInput.addEventListener('input', () => { clearTimeout(timeoutId); timeoutId = setTimeout(() => triggerSearch(searchInput.value), delay); }); // keyboard shortcut for entering search document.addEventListener('keyup', e => { // could additionally support '/' like GitHub and Gmail do, // but this would require overriding the default behavior of '/' on Firefox if (e.key === 's') searchInput.focus(); }); // keyboard navigation for search results searchInput.addEventListener('keydown', e => { const results = document.getElementById('search-results'); if (results !== null) { if (e.key === 'ArrowDown') { selectNextResult(results.firstElementChild); e.preventDefault(); } else if (e.key === 'ArrowUp') { selectPrevResult(results.firstElementChild); e.preventDefault(); } } }); searchInput.addEventListener('keyup', e => { if (e.key === 'Enter' && selectedSearchResult !== null) { selectedSearchResult.firstElementChild.click(); clearSearch(); } }); } // Initializes the search worker. function initSearchWorker() { const workerScriptUrl = rootUrlPrefix + 'scripts/search-worker.js'; try { searchWorker = new Worker(workerScriptUrl, {name: packageName === null ? "main" : packageName + '/' + packageVersion}); searchWorker.addEventListener('message', e => handleSearchResults(e.data.query, e.data.results)); } catch (e) { // could not initialize worker, presumably because we are a file:/// page and content security policy got in the way // fall back to running searches synchronously without a worker // this requires loading search related scripts that would otherwise be loaded by the worker searchWorker = null; let pendingScripts = 3; const onScriptLoaded = () => { if (--pendingScripts === 0) { initSearchIndex(); nonWorkerSearchInitialized = true; if (searchInput.focused) { triggerSearch(searchInput.value); } } }; const script1 = document.createElement('script'); script1.src = (packageName === null ? rootUrlPrefix : packageUrlPrefix) + 'search-index.js'; script1.async = true; script1.onload = onScriptLoaded; document.head.append(script1); const script2 = document.createElement('script'); script2.src = rootUrlPrefix; script2.async = true; script2.onload = onScriptLoaded; document.head.append(script2); const script3 = document.createElement('script'); script3.src = workerScriptUrl; script3.async = true; script3.onload = onScriptLoaded; document.head.append(script3); } } // Updates search results unless they are stale. function handleSearchResults(query, results) { if (query.inputValue !== searchInput.value) return; updateSearchResults(renderSearchResults(query, results)); } // TODO: Should this (or its callers) use requestAnimationFrame() ? // Removes any currently displayed search results, then displays the given results if non-null. function updateSearchResults(resultsDiv) { selectedSearchResult = null; const oldResultsDiv = document.getElementById('search-results'); if (oldResultsDiv !== null) { searchElement.removeChild(oldResultsDiv); } if (resultsDiv != null) { searchElement.append(resultsDiv); selectNextResult(resultsDiv.firstElementChild); } } // Returns the module of the given member, or `null` if the given member is a module. function getModule(member) { switch (member.level) { case 0: return null; case 1: return member.parent; case 2: return member.parent.parent; } } // Triggers a search unless search input is invalid or incomplete. function triggerSearch(inputValue) { const query = parseSearchInput(inputValue); if (!isActionableQuery(query)) { handleSearchResults(query, null); return; } if (searchWorker !== null) { searchWorker.postMessage({query, packageName, moduleName, className}); } else if (nonWorkerSearchInitialized) { const results = runSearch(query, packageName, moduleName, className); handleSearchResults(query, results); } } // Tells if the given Unicode character is a whitespace character. function isWhitespace(ch) { const cp = ch.codePointAt(0); if (cp >= 9 && cp <= 13 || cp === 32 || cp === 133 || cp === 160) return true; if (cp < 5760) return false; return cp === 5760 || cp >= 8192 && cp <= 8202 || cp === 8232 || cp === 8233 || cp === 8239 || cp === 8287 || cp === 12288; } // Trims the given Unicode characters. function trim(chars) { const length = chars.length; let startIdx, endIdx; for (startIdx = 0; startIdx < length; startIdx += 1) { if (!isWhitespace(chars[startIdx])) break; } for (endIdx = chars.length - 1; endIdx > startIdx; endIdx -= 1) { if (!isWhitespace(chars[endIdx])) break; } return chars.slice(startIdx, endIdx + 1); } // Parses the user provided search input. // Preconditions: // inputValue !== '' function parseSearchInput(inputValue) { const chars = trim(Array.from(inputValue)); const char0 = chars[0]; // may be undefined const char1 = chars[1]; // may be undefined const prefix = char1 === ':' ? char0 + char1 : null; const kind = prefix === null ? null : char0 === 'm' ? 1 : char0 === 't' ? 2 : char0 === 'c' ? 3 : char0 === 'f' ? 4 : char0 === 'p' ? 5 : undefined; const unprefixedChars = kind !== null && kind !== undefined ? trim(chars.slice(2, chars.length)) : chars; const normalizedCps = toNormalizedCodePoints(unprefixedChars); return {inputValue, prefix, kind, normalizedCps}; } // Converts a Unicode character array to an array of normalized Unicode code points. // Normalization turns characters into their base forms, e.g., é into e. // Since JS doesn't support case folding, `toLocaleLowerCase()` is used instead. // Note: Keep in sync with same function in search-worker.js. function toNormalizedCodePoints(characters) { return Uint32Array.from(characters, ch => ch.normalize('NFD')[0].toLocaleLowerCase().codePointAt(0)); } // Tells if the given query is valid and long enough to be worth running. // Prefixed queries require fewer minimum characters than unprefixed queries. // This avoids triggering a search while typing a prefix yet still enables searching for single-character names. // For example, `p:e` finds `pkl.math#E`. function isActionableQuery(query) { const kind = query.kind; const queryCps = query.normalizedCps; return kind !== undefined && (kind !== null && queryCps.length > 0 || queryCps.length > 1); } // Renders the given search results for the given query. // Preconditions: // isActionableQuery(query) ? results !== null : results === null function renderSearchResults(query, results) { const resultsDiv = document.createElement('div'); resultsDiv.id = 'search-results'; const ul = document.createElement('ul'); resultsDiv.append(ul); if (results === null) { if (query.kind !== undefined) return null; const li = document.createElement('li'); li.className = 'heading'; li.textContent = 'Unknown search prefix. Use one of m: (module), c: (class), f: (function), or p: (property).'; ul.append(li); return resultsDiv; } const {exactMatches, classMatches, moduleMatches, otherMatches} = results; if (exactMatches.length + classMatches.length + moduleMatches.length + otherMatches.length === 0) { renderHeading('No results found', ul); return resultsDiv; } if (exactMatches.length > 0) { renderHeading('Top hits', ul); renderMembers(query.normalizedCps, exactMatches, ul); } if (classMatches.length > 0) { renderHeading('Class', ul, className); renderMembers(query.normalizedCps, classMatches, ul); } if (moduleMatches.length > 0) { renderHeading('Module', ul, moduleName); renderMembers(query.normalizedCps, moduleMatches, ul); } if (otherMatches.length > 0) { renderHeading('Other results', ul); renderMembers(query.normalizedCps, otherMatches, ul); } return resultsDiv; } // Adds a heading such as `Top matches` to the search results list. function renderHeading(title, ul, name = null) { const li = document.createElement('li'); li.className = 'heading'; li.append(title); if (name != null) { li.append(' '); li.append(span('heading-name', name)) } ul.append(li); } // Adds matching members to the search results list. function renderMembers(queryCps, members, ul) { for (const member of members) { ul.append(renderMember(queryCps, member)); } } // Renders a member to be added to the search result list. function renderMember(queryCps, member) { const result = document.createElement('li'); result.className = 'result'; if (member.deprecated) result.className = 'deprecated'; const link = document.createElement('a'); result.append(link); link.href = (packageName === null ? rootUrlPrefix : packageUrlPrefix) + member.url; link.addEventListener('mousedown', () => selectResult(result)); link.addEventListener('click', clearSearch); const keyword = getKindKeyword(member.kind); // noinspection JSValidateTypes (IntelliJ bug?) if (keyword !== null) { link.append(span('keyword', keyword), ' '); } // prefix with class name if a class member if (member.level === 2) { link.append(span("context", member.parent.name + '.')); } const name = span('result-name'); if (member.matchNameIdx === 0) { // main name matched highlightMatch(queryCps, member.names[0], member.matchStartIdx, name); } else { // aka name matched name.append(member.name); } link.append(name); if (member.signature !== null) { link.append(member.signature); } if (member.matchNameIdx > 0) { // aka name matched link.append(' '); const aka = span('aka'); aka.append('(known as: '); const name = span('aka-name'); highlightMatch(queryCps, member.names[member.matchNameIdx], member.matchStartIdx, name); aka.append(name, ')'); link.append(aka); } // add module name if not a module const module = getModule(member); if (module !== null) { link.append(' ', span('context', '(' + module.name + ')')); } return result; } // Returns the keyword for the given member kind. function getKindKeyword(kind) { switch (kind) { case 0: return "package"; case 1: return "module"; case 2: return "typealias"; case 3: return "class"; case 4: return "function"; case 5: // properties have no keyword return null; } } // Highlights the matching characters in a member name. // Preconditions: // queryCps.length > 0 // computeMatchFrom(queryCps, name.normalizedCps, name.wordStarts, matchStartIdx) function highlightMatch(queryCps, name, matchStartIdx, parentElem) { const queryLength = queryCps.length; const codePoints = name.codePoints; const nameCps = name.normalizedCps; const nameLength = nameCps.length; const wordStarts = name.wordStarts; let queryIdx = 0; let queryCp = queryCps[0]; let startIdx = matchStartIdx; if (startIdx > 0) { parentElem.append(String.fromCodePoint(...codePoints.subarray(0, startIdx))); } for (let nameIdx = startIdx; nameIdx < nameLength; nameIdx += 1) { const nameCp = nameCps[nameIdx]; if (queryCp !== nameCp) { const newNameIdx = wordStarts[nameIdx]; parentElem.append( span('highlight', String.fromCodePoint(...codePoints.subarray(startIdx, nameIdx)))); startIdx = newNameIdx; parentElem.append(String.fromCodePoint(...codePoints.subarray(nameIdx, newNameIdx))); nameIdx = newNameIdx; } queryIdx += 1; if (queryIdx === queryLength) { parentElem.append( span('highlight', String.fromCodePoint(...codePoints.subarray(startIdx, nameIdx + 1)))); if (nameIdx + 1 < nameLength) { parentElem.append(String.fromCodePoint(...codePoints.subarray(nameIdx + 1, nameLength))); } return; } queryCp = queryCps[queryIdx]; } throw 'Precondition violated: `computeMatchFrom()`'; } // Creates a span element. function span(className, text = null) { const result = document.createElement('span'); result.className = className; result.textContent = text; return result; } // Creates a text node. function text(content) { return document.createTextNode(content); } // Navigates to the next member entry in the search results list, skipping headings. function selectNextResult(ul) { let next = selectedSearchResult === null ? ul.firstElementChild : selectedSearchResult.nextElementSibling; while (next !== null) { if (!next.classList.contains('heading')) { selectResult(next); scrollIntoView(next, { behavior: 'instant', // better for keyboard navigation scrollMode: 'if-needed', block: 'nearest', inline: 'nearest', }); return; } next = next.nextElementSibling; } } // Navigates to the previous member entry in the search results list, skipping headings. function selectPrevResult(ul) { let prev = selectedSearchResult === null ? ul.lastElementChild : selectedSearchResult.previousElementSibling; while (prev !== null) { if (!prev.classList.contains('heading')) { selectResult(prev); const prev2 = prev.previousElementSibling; // make any immediately preceding heading visible as well (esp. important for first heading) const scrollTo = prev2 !== null && prev2.classList.contains('heading') ? prev2 : prev; scrollIntoView(scrollTo, { behavior: 'instant', // better for keyboard navigation scrollMode: 'if-needed', block: 'nearest', inline: 'nearest', }); return; } prev = prev.previousElementSibling; } } // Selects the given entry in the search results list. function selectResult(li) { if (selectedSearchResult !== null) { selectedSearchResult.classList.remove('selected'); } li.classList.add('selected'); selectedSearchResult = li; } // Clears the search input and hides/removes the search results list. function clearSearch() { searchInput.value = ''; updateSearchResults(null); } // Functions called by JS data scripts. // noinspection JSUnusedGlobalSymbols const runtimeData = { links: (id, linksJson) => { const links = JSON.parse(linksJson); const fragment = document.createDocumentFragment(); let first = true; for (const link of links) { const {text, href, classes} = link const a = document.createElement("a"); a.textContent = text; if (href) a.href = href; if (classes) a.className = classes; if (first) { first = false; } else { fragment.append(", "); } fragment.append(a); } const element = document.getElementById(id); element.append(fragment); element.classList.remove("hidden"); // dd element.previousElementSibling.classList.remove("hidden"); // dt } }