Index: appengine/monorail/static/js/tracker/tracker-keystrokes.js |
diff --git a/appengine/monorail/static/js/tracker/tracker-keystrokes.js b/appengine/monorail/static/js/tracker/tracker-keystrokes.js |
new file mode 100644 |
index 0000000000000000000000000000000000000000..c12a908734526c49083fce02ea336559cdb9ab81 |
--- /dev/null |
+++ b/appengine/monorail/static/js/tracker/tracker-keystrokes.js |
@@ -0,0 +1,405 @@ |
+/* Copyright 2016 The Chromium Authors. All Rights Reserved. |
+ * |
+ * Use of this source code is governed by a BSD-style |
+ * license that can be found in the LICENSE file or at |
+ * https://developers.google.com/open-source/licenses/bsd |
+ */ |
+ |
+/** |
+ * This file contains JS functions that implement keystroke accelerators |
+ * for Monorail. |
+ */ |
+ |
+/** |
+ * Array of HTML elements where the kibbles cursor can be. E.g., |
+ * the TR elements of an issue list, or the TR's for comments on an issue. |
+ */ |
+var TKR_cursorStops; |
+ |
+/** |
+ * Integer index into TKR_cursorStops of the currently selected cursor |
+ * stop, or undefined if nothing has been selected yet. |
+ */ |
+var TKR_selected = undefined; |
+ |
+ |
+/** |
+ * Scroll to the issue search field, set keyboard focus there, and |
+ * select all of its text contents. We use <span id="qq"> around |
+ * that form field because IE has a broken getElementById that |
+ * confuses id with form element names. We do this in a setTimeout() |
+ * so that the keystroke that triggers it ('/') will not be typed into |
+ * the search box itself. |
+ */ |
+function TKR_focusArtifactSearchField() { |
+ var el = TKR_getArtifactSearchField(); |
+ el.focus(); // forces browser to scroll to make field visible. |
+ el.select(); |
+} |
+ |
+ |
+/** |
+ * Always hide the keystroke help overlay, if it has been loaded. |
+ */ |
+function TKR_closeKeystrokeHelp() { |
+ var dialog = document.getElementById('keys_help'); |
+ if (dialog) { |
+ dialog.style.display = 'none'; |
+ } |
+} |
+ |
+/** |
+ * Show or hide the keystroke help overlay. If it has not been loaded |
+ * yet, make the request to load it. |
+ */ |
+function TKR_toggleKeystrokeHelp() { |
+ var dialog = document.getElementById('keys_help'); |
+ if (dialog) { |
+ dialog.style.display = dialog.style.display ? '' : 'none'; |
+ } else { |
+ TKR_buildKeystrokeHelp(); |
+ } |
+} |
+ |
+function TKR_createChild(parentEl, tag, optClassName, optID, optText, optStyle) { |
+ var el = document.createElement(tag); |
+ if (optClassName) el.classList.add(optClassName); |
+ if (optID) el.id = optID; |
+ if (optText) el.innerText = optText; |
+ if (optStyle) el.setAttribute('style', optStyle); |
+ parentEl.appendChild(el); |
+ return el; |
+} |
+ |
+function TKR_createKeysHelpHeader(row, text) { |
+ TKR_createChild(row, 'td'); |
+ TKR_createChild(row, 'th', null, null, text); |
+ return row; |
+} |
+ |
+function TKR_createKeysHelpItem(row, key1, key2, doc) { |
+ var keyCell = TKR_createChild(row, 'td', 'shortcut'); |
+ TKR_createChild(keyCell, 'span', 'keystroke', null, key1); |
+ if (key2) { |
+ keyCell.appendChild(document.createTextNode(' / ')); |
+ TKR_createChild(keyCell, 'span', 'keystroke', null, key2); |
+ } |
+ TKR_createChild(keyCell, 'b', null, null, ' :'); |
+ |
+ TKR_createChild(row, 'td', null, null, doc); |
+ return keyCell; |
+} |
+ |
+/** |
+ * Build the keystroke help dialog. It is not part of the template because it |
+ * not used on the vast majority of pages viewed. |
+ */ |
+function TKR_buildKeystrokeHelp() { |
+ var helpArea = document.getElementById('helparea'); |
+ var dialog = TKR_createChild( |
+ helpArea, 'div', 'fullscreen-popup', 'keys_help'); |
+ var closeX = TKR_createChild( |
+ dialog, 'a', null, null, 'Close', 'float:right; font-size:140%'); |
+ closeX.href = '#'; |
+ closeX.addEventListener('click', function () { |
+ $('keys_help').style.display = 'none'; |
+ }); |
+ TKR_createChild( |
+ dialog, 'div', null, null, 'Issue tracker keyboard shortcuts', |
+ 'font-size: 140%'); |
+ TKR_createChild(dialog, 'hr'); |
+ |
+ var keysTable = TKR_createChild( |
+ dialog, 'table', null, null, null, 'width: 100%'); |
+ var headerRow = TKR_createChild(keysTable, 'tr'); |
+ TKR_createKeysHelpHeader(headerRow, 'Issue list'); |
+ TKR_createKeysHelpHeader(headerRow, 'Issue details'); |
+ TKR_createKeysHelpHeader(headerRow, 'Anywhere') |
+ var row1 = TKR_createChild(keysTable, 'tr'); |
+ TKR_createKeysHelpItem(row1, 'k', 'j', 'up/down in the list'); |
+ TKR_createKeysHelpItem(row1, 'k', 'j', 'prev/next issue in list'); |
+ TKR_createKeysHelpItem(row1, '/', null, 'focus on the issue search field'); |
+ var row2 = TKR_createChild(keysTable, 'tr'); |
+ TKR_createKeysHelpItem(row2, 'o', '<Enter>', 'open the current issue'); |
+ TKR_createKeysHelpItem(row2, 'u', null, 'up to issue list'); |
+ TKR_createKeysHelpItem(row2, 'c', null, 'compose a new issue'); |
+ var row3 = TKR_createChild(keysTable, 'tr'); |
+ TKR_createKeysHelpItem(row3, 'Shift-O', null, 'open issue in new tab'); |
+ TKR_createKeysHelpItem(row3, 'p', 'n', 'prev/next comment'); |
+ TKR_createKeysHelpItem(row3, 's', null, 'star the current issue'); |
+ var row4 = TKR_createChild(keysTable, 'tr'); |
+ TKR_createKeysHelpItem(row4, 'x', null, 'select the current issue'); |
+ TKR_createKeysHelpItem(row4, 'r', null, 'add comment & make changes'); |
+ TKR_createKeysHelpItem(row4, '?', null, 'show this help dialog'); |
+ |
+ var footer = TKR_createChild( |
+ dialog, 'div', null, null, null, |
+ 'font-weight:normal; margin-top: 3em;'); |
+ TKR_createChild(footer, 'hr'); |
+ TKR_createChild(footer, 'div', null, null, |
+ ('Note: Only signed in users can star issues or add comments, ' + |
+ 'and only project members can select issues for bulk edits.')); |
+} |
+ |
+ |
+/** |
+ * Register keystrokes that apply to all pages in the current component. |
+ * E.g., keystrokes that should work on every page under the "Issues" tab. |
+ * @param {string} listUrl Rooted URL of the artifact list. |
+ * @param {string} entryUrl Rooted URL of the artifact entry page. |
+ * @param {string} currentPageType One of 'list', 'entry', or 'detail'. |
+ */ |
+function TKR_setupKibblesComponentKeys(listUrl, entryUrl, currentPageType) { |
+ kibbles.keys.addKeyPressListener( |
+ '/', |
+ function() { |
+ window.setTimeout(TKR_focusArtifactSearchField, 10); |
+ }); |
+ if (currentPageType != 'entry') { |
+ kibbles.keys.addKeyPressListener( |
+ 'c', function() { TKR_go(entryUrl); }); |
+ } |
+ if (currentPageType != 'list') { |
+ kibbles.keys.addKeyPressListener( |
+ 'u', function() { TKR_go(listUrl); }); |
+ } |
+ kibbles.keys.addKeyPressListener('?', TKR_toggleKeystrokeHelp); |
+ |
+ kibbles.keys.addKeyPressListener('ESC', TKR_closeKeystrokeHelp); |
+} |
+ |
+ |
+/** |
+ * On the artifact list page, go tp the artifact at the kibbles cursor. |
+ * @param {number} linkCellIndex row child that is expected to hold a link. |
+ */ |
+function TKR_openArtifactAtCursor(linkCellIndex, newWindow) { |
+ if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) { |
+ var cell = TKR_cursorStops[TKR_selected].children[linkCellIndex]; |
+ var anchor = cell.children[0]; |
+ if (anchor) { |
+ TKR_go(anchor.getAttribute('href'), newWindow); |
+ } |
+ } |
+} |
+ |
+ |
+/** |
+ * On the artifact list page, toggle the checkbox for the artifact at |
+ * the kibbles cursor. |
+ * @param {number} cbCellIndex row child that is expected to hold a checkbox. |
+ */ |
+function TKR_selectArtifactAtCursor(cbCellIndex) { |
+ if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) { |
+ var cell = TKR_cursorStops[TKR_selected].children[cbCellIndex]; |
+ var cb = cell.firstChild; |
+ while (cb && cb.tagName != 'INPUT') { |
+ cb = cb.nextSibling; |
+ } |
+ if (cb) { |
+ cb.checked = cb.checked ? '' : 'checked'; |
+ TKR_highlightRow(cb); |
+ } |
+ } |
+} |
+ |
+/** |
+ * On the artifact list page, toggle the star for the artifact at |
+ * the kibbles cursor. |
+ * @param {number} cbCellIndex row child that is expected to hold a checkbox |
+ * and star widget. |
+ * @param {string} set_star_token The security token. |
+ */ |
+function TKR_toggleStarArtifactAtCursor(cbCellIndex, set_star_token) { |
+ if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) { |
+ var cell = TKR_cursorStops[TKR_selected].children[cbCellIndex]; |
+ var starIcon = cell.firstChild; |
+ while (starIcon && starIcon.tagName != 'A') { |
+ starIcon = starIcon.nextSibling; |
+ } |
+ if (starIcon) { |
+ _TKR_toggleStar( |
+ starIcon, issueRefs[TKR_selected]['project_name'], |
+ issueRefs[TKR_selected]['id'], set_star_token); |
+ } |
+ } |
+} |
+ |
+/** |
+ * Updates the style on new stop and clears the style on the former stop. |
+ * @param {Object} newStop the cursor stop that the user is selecting now. |
+ * @param {Object} formerStop the old cursor stop, if any. |
+ */ |
+function TKR_updateCursor(newStop, formerStop) { |
+ TKR_selected = undefined; |
+ if (formerStop) { |
+ formerStop.element.classList.remove('cursor_on'); |
+ formerStop.element.classList.add('cursor_off'); |
+ } |
+ if (newStop && newStop.element) { |
+ newStop.element.classList.remove('cursor_off'); |
+ newStop.element.classList.add('cursor_on'); |
+ TKR_selected = newStop.index; |
+ } |
+} |
+ |
+ |
+/** |
+ * Walk part of the page DOM to find elements that should be kibbles |
+ * cursor stops. E.g., the rows of the issue list results table. |
+ * @return {Array} an array of html elements. |
+ */ |
+function TKR_findCursorRows() { |
+ var rows = []; |
+ var cursorarea = document.getElementById('cursorarea'); |
+ TKR_accumulateCursorRows(cursorarea, rows); |
+ return rows; |
+} |
+ |
+ |
+/** |
+ * Recusrively walk part of the page DOM to find elements that should |
+ * be kibbles cursor stops. E.g., the rows of the issue list results |
+ * table. The cursor stops are appended to the given rows array. |
+ * @param {Element} parent html element to start on. |
+ * @param {Array} rows array of html TR or DIV elements, each cursor stop will |
+ * be added to this array. |
+ */ |
+function TKR_accumulateCursorRows(parent, rows) { |
+ for (var i = 0; i < parent.childNodes.length; i++) { |
+ var elem = parent.childNodes[i]; |
+ var name = elem.tagName; |
+ if (name && (name == 'TR' || name == 'DIV')) { |
+ if (elem.className.indexOf('cursor') >= 0) { |
+ elem.cursorIndex = rows.length; |
+ rows.push(elem); |
+ } |
+ } |
+ TKR_accumulateCursorRows(elem, rows); |
+ } |
+} |
+ |
+ |
+/** |
+ * Initialize kibbles cursors stops for the current page. |
+ * @param {boolean} selectFirstStop True if the first stop should be |
+ * selected before the user presses any keys. |
+ */ |
+function TKR_setupKibblesCursorStops(selectFirstStop) { |
+ kibbles.skipper.addStopListener( |
+ kibbles.skipper.LISTENER_TYPE.PRE, TKR_updateCursor); |
+ |
+ // Set the 'offset' option to return the middle of the client area |
+ // an option can be a static value, or a callback |
+ kibbles.skipper.setOption('padding_top', 50); |
+ |
+ // Set the 'offset' option to return the middle of the client area |
+ // an option can be a static value, or a callback |
+ kibbles.skipper.setOption('padding_bottom', 50); |
+ |
+ // register our stops with skipper |
+ TKR_cursorStops = TKR_findCursorRows(); |
+ for (var i = 0; i < TKR_cursorStops.length; i++) { |
+ var element = TKR_cursorStops[i]; |
+ kibbles.skipper.append(element); |
+ |
+ if (element.className.indexOf('cursor_on') >= 0) { |
+ kibbles.skipper.setCurrentStop(i); |
+ } |
+ } |
+} |
+ |
+ |
+/** |
+ * Initialize kibbles keystrokes for an artifact entry page. |
+ * @param {string} listUrl Rooted URL of the artifact list. |
+ * @param {string} entryUrl Rooted URL of the artifact entry page. |
+ */ |
+function TKR_setupKibblesOnEntryPage(listUrl, entryUrl) { |
+ TKR_setupKibblesComponentKeys(listUrl, entryUrl, 'entry'); |
+} |
+ |
+ |
+/** |
+ * Initialize kibbles keystrokes for an artifact list page. |
+ * @param {string} listUrl Rooted URL of the artifact list. |
+ * @param {string} entryUrl Rooted URL of the artifact entry page. |
+ * @param {string} projectName Name of the current project. |
+ * @param {number} linkCellIndex table column that is expected to |
+ * link to individual artifacts. |
+ * @param {number} opt_checkboxCellIndex table column that is expected |
+ * to contain a selection checkbox. |
+ * @param {string} set_star_token The security token. |
+ */ |
+function TKR_setupKibblesOnListPage( |
+ listUrl, entryUrl, projectName, linkCellIndex, |
+ opt_checkboxCellIndex, set_star_token) { |
+ TKR_setupKibblesCursorStops(true); |
+ |
+ kibbles.skipper.addFwdKey('j'); |
+ kibbles.skipper.addRevKey('k'); |
+ |
+ if (opt_checkboxCellIndex != undefined) { |
+ var cbCellIndex = opt_checkboxCellIndex; |
+ kibbles.keys.addKeyPressListener( |
+ 'x', function() { TKR_selectArtifactAtCursor(cbCellIndex); }); |
+ kibbles.keys.addKeyPressListener( |
+ 's', |
+ function() { |
+ TKR_toggleStarArtifactAtCursor(cbCellIndex, set_star_token); |
+ }); |
+ } |
+ kibbles.keys.addKeyPressListener( |
+ 'o', function() { TKR_openArtifactAtCursor(linkCellIndex, false); }); |
+ kibbles.keys.addKeyPressListener( |
+ 'O', function() { TKR_openArtifactAtCursor(linkCellIndex, true); }); |
+ kibbles.keys.addKeyPressListener( |
+ 'enter', function() { TKR_openArtifactAtCursor(linkCellIndex); }); |
+ |
+ TKR_setupKibblesComponentKeys(listUrl, entryUrl, 'list'); |
+} |
+ |
+ |
+/** |
+ * Initialize kibbles keystrokes for an artifact detail page. |
+ * @param {string} listUrl Rooted URL of the artifact list. |
+ * @param {string} entryUrl Rooted URL of the artifact entry page. |
+ * @param {string} prevUrl Rooted URL of previous artifact in list. |
+ * @param {string} nextUrl Rooted URL of next artifact in list. |
+ * @param {string} projectName name of the current project. |
+ * @param {boolean} userCanComment True if the user may add a comment. |
+ * @param {boolean} userCanStar True if the user may add a star. |
+ * @param {string} set_star_token The security token. |
+ */ |
+function TKR_setupKibblesOnDetailPage( |
+ listUrl, entryUrl, prevUrl, nextUrl, projectName, localId, |
+ userCanComment, userCanStar, set_star_token) { |
+ TKR_setupKibblesCursorStops(false); |
+ kibbles.skipper.addFwdKey('n'); |
+ kibbles.skipper.addRevKey('p'); |
+ if (prevUrl) { |
+ kibbles.keys.addKeyPressListener( |
+ 'k', function() { TKR_go(prevUrl); }); |
+ } |
+ if (nextUrl) { |
+ kibbles.keys.addKeyPressListener( |
+ 'j', function() { TKR_go(nextUrl); }); |
+ } |
+ if (userCanComment) { |
+ kibbles.keys.addKeyPressListener( |
+ 'r', |
+ function() { |
+ window.setTimeout(TKR_openIssueUpdateForm, 10); |
+ }); |
+ } |
+ if (userCanStar) { |
+ kibbles.keys.addKeyPressListener( |
+ 's', |
+ function() { |
+ var star = document.getElementById('star'); |
+ TKR_toggleStar(star, projectName, localId, set_star_token); |
+ TKR_syncStarIcons(star, 'star2'); |
+ }); |
+ } |
+ TKR_setupKibblesComponentKeys(listUrl, entryUrl, 'detail'); |
+} |