blob: bd6bf6bcce3c0e66fab90aaff80e65438f004653 [file] [log] [blame]
/**
* @license
* Copyright 2019-2020 The Go Authors. All rights reserved.
* Use of this source code is governed by a BSD-style
* license that can be found in the LICENSE file.
*/
// This file implements the behavior of the "jump to identifer" dialog for Go
// package documentation, as well as the simple dialog that displays keyboard
// shortcuts.
// The DOM for the dialogs is at the bottom of content/static/html/pages/pkg_doc.tmpl.
// The CSS is in content/static/css/stylesheet.css.
// The dialog is activated by pressing the 'f' key. It presents a list
// (#JumpDialog-list) of all Go identifiers displayed in the documentation.
// Entering text in the dialog's text box (#JumpDialog-filter) restricts the
// list to identifiers containing the text. Clicking on an identifier jumps to
// its documentation.
// This code is based on
// https://go.googlesource.com/gddo/+/refs/heads/master/gddo-server/assets/site.js.
// It was modified to remove the dependence on jquery and bootstrap.
const jumpDialog = document.querySelector('.JumpDialog');
const jumpBody = jumpDialog.querySelector('.JumpDialog-body');
const jumpList = jumpDialog.querySelector('.JumpDialog-list');
const jumpFilter = jumpDialog.querySelector('.JumpDialog-input');
const searchInput = document.querySelector('.js-searchFocus');
const doc = document.querySelector('.js-documentation');
if (!jumpDialog.showModal) {
dialogPolyfill.registerDialog(jumpDialog);
}
let jumpListItems; // All the identifiers in the doc; computed only once.
// collectJumpListItems returns a list of items, one for each identifier in the
// documentation on the current page.
//
// It uses the data-kind attribute generated in the documentation HTML to find
// the identifiers and their id attributes.
//
// If there are no data-kind attributes, then we have older doc; fall back to
// a less precise method.
function collectJumpListItems() {
let items = [];
for (const el of doc.querySelectorAll('[data-kind]')) {
items.push(newJumpListItem(el));
}
if (items.length == 0) {
items = collectJumpListItemsFallback(doc);
}
// Clicking on any of the links closes the dialog.
for (const item of items) {
item.link.addEventListener('click', function () {
jumpDialog.close();
});
}
// Sort case-insensitively by identifier name.
items.sort(function (a, b) {
return a.lower.localeCompare(b.lower);
});
return items;
}
function collectJumpListItemsFallback(doc) {
const items = [];
// A map from id to bool, to dedup DOM ids. The doc DOM has duplicate ids (b/143456059).
// We assume the first one is the one we want.
const seen = {};
// Attempt to find the relevant elements by looking through every element in the
// .Documentation DOM that has an id attribute of a certain form.
for (const el of doc.querySelectorAll('*[id]')) {
const id = el.getAttribute('id');
if (!seen[id] && /^[^_][^-]*$/.test(id)) {
seen[id] = true;
items.push(newJumpListItem(el));
}
}
return items;
}
// newJumpListItem creates a new item for the DOM element el.
// An item is an object with:
// - name: the element's id (which is the identifer name)
// - kind: the element's kind (function, variable, etc.),
// - link: a link ('a' tag) to the element
// - lower: the name in lower case, just for sorting
function newJumpListItem(el) {
const a = document.createElement('a');
const name = el.getAttribute('id');
a.setAttribute('href', '#' + name);
a.setAttribute('tabindex', '-1');
let kind = el.getAttribute('data-kind');
if (!kind) {
kind = guessKind(el);
}
return {
link: a,
name: name,
kind: kind,
lower: name.toLowerCase(), // for sorting
};
}
// guessKind tries to guess the kind of el by looking around the DOM.
// Fixing b/143456714 would make this unnecessary.
function guessKind(el) {
switch (el.getAttribute('class')) {
case 'Documentation-functionHeader':
case 'Documentation-typeFuncHeader':
return 'function';
case 'Documentation-typeHeader':
return 'type';
case 'Documentation-typeMethodHeader':
return 'method';
default:
const sec = el.closest('section');
switch (sec.getAttribute('class')) {
case 'Documentation-variables':
return 'variable';
case 'Documentation-constants':
return 'constant';
case 'Documentation-types':
return 'field';
default:
return '';
}
}
}
let lastFilterValue; // The last contents of the filter text box.
let activeJumpItem = -1; // The index of the currently active item in the list.
// updateJumpList sets the elements of the dialog list to
// everything whose name contains filter.
function updateJumpList(filter) {
lastFilterValue = filter;
if (!jumpListItems) {
jumpListItems = collectJumpListItems();
}
setActiveJumpItem(-1);
// Remove all children from list.
while (jumpList.firstChild) {
jumpList.firstChild.remove();
}
// Make a regexp corresponding to filter. The result will match any string
// containing filter, case-insensitively. Escape the regexp metacharacters in
// filter.
const re = new RegExp(filter.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'), 'gi');
for (const item of jumpListItems) {
var name = item.name;
if (filter) {
// Boldify the substring of name matching the filter.
name = name.replace(re, function (s) {
return '<b>' + s + '</b>';
});
if (name == item.name) {
// We didn't change name, so it didn't match the filter.
continue;
}
}
// The text we display includes the name (with filter match bolded), and the
// kind in italics.
item.link.innerHTML = name + ' <i>' + item.kind + '</i>';
jumpList.appendChild(item.link);
}
jumpBody.scrollTop = 0;
if (jumpList.children.length > 0) {
setActiveJumpItem(0);
}
}
// Set the active jump item to n.
function setActiveJumpItem(n) {
const cs = jumpList.children;
if (activeJumpItem >= 0) {
cs[activeJumpItem].classList.remove('JumpDialog-active');
}
if (n >= cs.length) {
n = cs.length - 1;
}
if (n >= 0) {
cs[n].classList.add('JumpDialog-active');
// Scroll so the active item is visible.
// For some reason cs[n].scrollIntoView() doesn't behave as I'd expect:
// it moves the entire dialog box in the viewport.
// Get the top and bottom of the active item relative to jumpBody.
const activeTop = cs[n].offsetTop - cs[0].offsetTop;
const activeBottom = activeTop + cs[n].clientHeight;
if (activeTop < jumpBody.scrollTop) {
// Off the top; scroll up.
jumpBody.scrollTop = activeTop;
} else if (activeBottom > jumpBody.scrollTop + jumpBody.clientHeight) {
// Off the bottom; scroll down.
jumpBody.scrollTop = activeBottom - jumpBody.clientHeight;
}
}
activeJumpItem = n;
}
// Increment the activeJumpItem by delta.
function incActiveJumpItem(delta) {
if (activeJumpItem < 0) {
return;
}
let n = activeJumpItem + delta;
if (n < 0) {
n = 0;
}
setActiveJumpItem(n);
}
// Pressing a key in the filter updates the list (if the filter actually changed).
jumpFilter.addEventListener('keyup', function (event) {
if (jumpFilter.value.toUpperCase() != lastFilterValue.toUpperCase()) {
updateJumpList(jumpFilter.value);
}
});
// Pressing enter in the filter selects the first element in the list.
jumpFilter.addEventListener('keydown', function (event) {
const upArrow = 38;
const downArrow = 40;
const enterKey = 13;
switch (event.which) {
case upArrow:
incActiveJumpItem(-1);
event.preventDefault();
break;
case downArrow:
incActiveJumpItem(1);
event.preventDefault();
break;
case enterKey:
if (activeJumpItem >= 0) {
jumpList.children[activeJumpItem].click();
}
break;
}
});
const shortcutsDialog = document.querySelector('.ShortcutsDialog');
if (!shortcutsDialog.showModal) {
dialogPolyfill.registerDialog(shortcutsDialog);
}
// Keyboard shortcuts:
// - Pressing '/' focuses the search box
// - Pressing 'f' or 'F' opens the jump-to-identifier dialog.
// - Pressing '?' opens up the shortcut dialog.
// Ignore a keypress if a dialog is already open, or if it is pressed on a
// component that wants to consume it.
document.addEventListener('keypress', function (e) {
if (jumpDialog.open || shortcutsDialog.open || !doc) {
return;
}
const t = e.target.tagName;
if (t == 'INPUT' || t == 'SELECT' || t == 'TEXTAREA') {
return;
}
if (e.target.contentEditable && e.target.contentEditable == 'true') {
return;
}
if (e.metaKey || e.ctrlKey) {
return;
}
const ch = String.fromCharCode(e.which);
switch (ch) {
case 'f':
case 'F':
e.preventDefault();
jumpFilter.value = '';
jumpDialog.showModal();
updateJumpList('');
break;
case '?':
shortcutsDialog.showModal();
break;
case '/':
// Favoring the Firefox quick find feature over search input
// focus. See: https://github.com/golang/go/issues/41093.
if (searchInput && !window.navigator.userAgent.includes('Firefox')) {
e.preventDefault();
searchInput.focus();
}
break;
}
});
const jumpOutlineInput = document.querySelector('.js-jumpToInput');
if (jumpOutlineInput) {
jumpOutlineInput.addEventListener('click', () => {
jumpFilter.value = '';
jumpDialog.showModal();
updateJumpList('');
});
}