diff options
Diffstat (limited to 'chromium/chrome/browser/resources/omnibox/omnibox_output.js')
-rw-r--r-- | chromium/chrome/browser/resources/omnibox/omnibox_output.js | 796 |
1 files changed, 796 insertions, 0 deletions
diff --git a/chromium/chrome/browser/resources/omnibox/omnibox_output.js b/chromium/chrome/browser/resources/omnibox/omnibox_output.js new file mode 100644 index 00000000000..619f2311144 --- /dev/null +++ b/chromium/chrome/browser/resources/omnibox/omnibox_output.js @@ -0,0 +1,796 @@ +// Copyright 2018 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. + +cr.define('omnibox_output', function() { + /** + * Details how to display an autocomplete result data field. + * @typedef {{ + * header: string, + * url: string, + * propertyName: string, + * displayAlways: boolean, + * tooltip: string, + * }} + */ + let PresentationInfoRecord; + + /** + * A constant that's used to decide what autocomplete result + * properties to output in what order. + * @type {!Array<!PresentationInfoRecord>} + */ + const PROPERTY_OUTPUT_ORDER = [ + { + header: 'Provider', + url: '', + propertyName: 'providerName', + displayAlways: true, + tooltip: 'The AutocompleteProvider suggesting this result.' + }, + { + header: 'Type', + url: '', + propertyName: 'type', + displayAlways: true, + tooltip: 'The type of the result.' + }, + { + header: 'Relevance', + url: '', + propertyName: 'relevance', + displayAlways: true, + tooltip: 'The result score. Higher is more relevant.' + }, + { + header: 'Contents', + url: '', + propertyName: 'contents', + displayAlways: true, + tooltip: 'The text that is presented identifying the result.' + }, + { + header: 'Description', + url: '', + propertyName: 'description', + displayAlways: false, + tooltip: 'The page title of the result.' + }, + { + header: 'CanBeDefault', + url: '', + propertyName: 'allowedToBeDefaultMatch', + displayAlways: false, + tooltip: + 'A green checkmark indicates that the result can be the default ' + + 'match(i.e., can be the match that pressing enter in the omnibox' + + 'navigates to).' + }, + { + header: 'Starred', + url: '', + propertyName: 'starred', + displayAlways: false, + tooltip: + 'A green checkmark indicates that the result has been bookmarked.' + }, + { + header: 'Hastabmatch', + url: '', + propertyName: 'hasTabMatch', + displayAlways: false, + tooltip: + 'A green checkmark indicates that the result URL matches an open' + + 'tab.' + }, + { + header: 'URL', + url: '', + propertyName: 'destinationUrl', + displayAlways: true, + tooltip: 'The URL for the result.' + }, + { + header: 'FillIntoEdit', + url: '', + propertyName: 'fillIntoEdit', + displayAlways: false, + tooltip: 'The text shown in the omnibox when the result is selected.' + }, + { + header: 'InlineAutocompletion', + url: '', + propertyName: 'inlineAutocompletion', + displayAlways: false, + tooltip: 'The text shown in the omnibox as a blue highlight selection ' + + 'following the cursor, if this match is shown inline.' + }, + { + header: 'Del', + url: '', + propertyName: 'deletable', + displayAlways: false, + tooltip: + 'A green checkmark indicates that the result can be deleted from ' + + 'the visit history.' + }, + { + header: 'Prev', + url: '', + propertyName: 'fromPrevious', + displayAlways: false, + tooltip: '' + }, + { + header: 'Tran', + url: + 'https://cs.chromium.org/chromium/src/ui/base/page_transition_types.h?q=page_transition_types.h&sq=package:chromium&dr=CSs&l=14', + propertyName: 'transition', + displayAlways: false, + tooltip: 'How the user got to the result.' + }, + { + header: 'Done', + url: '', + propertyName: 'providerDone', + displayAlways: false, + tooltip: + 'A green checkmark indicates that the provider is done looking ' + + 'for more results.' + }, + { + header: 'AssociatedKeyword', + url: '', + propertyName: 'associatedKeyword', + displayAlways: false, + tooltip: 'If non-empty, a "press tab to search" hint will be shown and ' + + 'will engage this keyword.' + }, + { + header: 'Keyword', + url: '', + propertyName: 'keyword', + displayAlways: false, + tooltip: 'The keyword of the search engine to be used.' + }, + { + header: 'Duplicates', + url: '', + propertyName: 'duplicates', + displayAlways: false, + tooltip: 'The number of matches that have been marked as duplicates of ' + + 'this match..' + }, + { + header: 'AdditionalInfo', + url: '', + propertyName: 'additionalInfo', + displayAlways: false, + tooltip: 'Provider-specific information about the result.' + } + ]; + + /** + * In addition to representing the rendered HTML element, OmniboxOutput also + * provides a single public interface to interact with the output: + * 1. Render tables from responses (RenderDelegate) + * 2. Control visibility based on display options (TODO) + * 3. Control visibility and coloring based on search text (FilterDelegate) + * 4. Export and copy output (CopyDelegate) + * 5. Preserve inputs and reset inputs to default (TODO) + * 6. Export and import inputs (TODO) + * With regards to interacting with RenderDelegate, OmniboxOutput tracks and + * aggregates responses from the C++ autocomplete controller. Typically, the + * C++ controller returns 3 sets of results per query, unless a new query is + * submitted before all 3 responses. OmniboxController also triggers + * appending to and clearing of OmniboxOutput when appropriate (e.g., upon + * receiving a new response or a change in display inputs). + */ + class OmniboxOutput extends OmniboxElement { + /** @return {string} */ + static get is() { + return 'omnibox-output'; + } + + constructor() { + super('omnibox-output-template'); + + /** @type {!RenderDelegate} */ + this.renderDelegate = new RenderDelegate(this.$$('contents')); + /** @type {!CopyDelegate} */ + this.copyDelegate = new CopyDelegate(this); + /** @type {!FilterDelegate} */ + this.filterDelegate = new FilterDelegate(this); + + /** @type {!Array<!mojom.OmniboxResult>} */ + this.responses = []; + /** @private {!QueryInputs} */ + this.queryInputs_ = /** @type {!QueryInputs} */ ({}); + /** @private {!DisplayInputs} */ + this.displayInputs_ = /** @type {!DisplayInputs} */ ({}); + } + + /** @param {!QueryInputs} queryInputs */ + updateQueryInputs(queryInputs) { + this.queryInputs_ = queryInputs; + this.refresh_(); + } + + /** @param {!DisplayInputs} displayInputs */ + updateDisplayInputs(displayInputs) { + this.displayInputs_ = displayInputs; + this.refresh_(); + } + + clearAutocompleteResponses() { + this.responses = []; + this.refresh_(); + } + + /** @param {!mojom.OmniboxResult} response */ + addAutocompleteResponse(response) { + this.responses.push(response); + this.refresh_(); + } + + /** @private */ + refresh_() { + this.renderDelegate.refresh( + this.queryInputs_, this.responses, this.displayInputs_); + } + + /** @return {!Array<!OutputMatch>} */ + get matches() { + return this.renderDelegate.matches; + } + } + + // Responsible for rendering the output HTML. + class RenderDelegate { + /** @param {!Element} containerElement */ + constructor(containerElement) { + /** @private {!Element} */ + this.containerElement_ = containerElement; + } + + /** + * @param {!QueryInputs} queryInputs + * @param {!Array<!mojom.OmniboxResult>} responses + * @param {!DisplayInputs} displayInputs + */ + refresh(queryInputs, responses, displayInputs) { + if (!responses.length) + return; + + /** @private {!Array<OutputResultsGroup>} */ + this.resultsGroup_; + + if (displayInputs.showIncompleteResults) { + this.resultsGroup_ = responses.map( + response => + new OutputResultsGroup(response, queryInputs.cursorPosition)); + } else { + const lastResponse = responses[responses.length - 1]; + this.resultsGroup_ = + [new OutputResultsGroup(lastResponse, queryInputs.cursorPosition)]; + } + + this.clearOutput_(); + this.resultsGroup_.forEach( + resultsGroup => + this.containerElement_.appendChild(resultsGroup.render( + displayInputs.showDetails, + displayInputs.showIncompleteResults, + displayInputs.showAllProviders))); + } + + /** @private */ + clearOutput_() { + const contents = this.containerElement_; + // Clears all children. + while (contents.firstChild) + contents.removeChild(contents.firstChild); + } + + /** @return {string} */ + get visibletableText() { + return this.containerElement_.innerText; + } + + /** @return {!Array<!OutputMatch>} */ + get matches() { + return this.resultsGroup_.flatMap(resultsGroup => resultsGroup.matches); + } + } + + /** + * Helps track and render a results group. C++ Autocomplete typically returns + * 3 result groups per query. It may return less if the next query is + * submitted before all 3 have been returned. Each result group contains + * top level information (e.g., how long the result took to generate), as well + * as a single list of combined results and multiple lists of individual + * results. Each of these lists is tracked and rendered by OutputResultsTable + * below. + */ + class OutputResultsGroup { + /** + * @param {!mojom.OmniboxResult} resultsGroup + * @param {number} cursorPosition + */ + constructor(resultsGroup, cursorPosition) { + /** @struct */ + this.details = { + cursorPosition, + time: resultsGroup.timeSinceOmniboxStartedMs, + done: resultsGroup.done, + host: resultsGroup.host, + isTypedHost: resultsGroup.isTypedHost + }; + /** @type {!OutputResultsTable} */ + this.combinedResults = + new OutputResultsTable(resultsGroup.combinedResults); + /** @type {!Array<!OutputResultsTable>} */ + this.individualResultsList = + resultsGroup.resultsByProvider + .map(resultsWrapper => resultsWrapper.results) + .filter(results => results.length > 0) + .map(results => new OutputResultsTable(results)); + } + + /** + * Creates a HTML Node representing this data. + * @param {boolean} showDetails + * @param {boolean} showIncompleteResults + * @param {boolean} showAllProviders + * @return {!Element} + */ + render(showDetails, showIncompleteResults, showAllProviders) { + const detailsAndTable = + OmniboxElement.getTemplate('details-and-table-template'); + if (showDetails || showIncompleteResults) { + detailsAndTable.querySelector('.details') + .appendChild(this.renderDetails_()); + } + + const showAdditionalPropertiesColumn = + this.showAdditionalPropertiesColumn_(showDetails); + + detailsAndTable.querySelector('.table').appendChild( + OutputResultsTable.renderHeader( + showDetails, showAdditionalPropertiesColumn)); + detailsAndTable.querySelector('.table').appendChild( + this.combinedResults.render(showDetails)); + if (showAllProviders) { + this.individualResultsList.forEach(individualResults => { + detailsAndTable.querySelector('.table').appendChild( + individualResults.renderInnerHeader( + showDetails, showAdditionalPropertiesColumn)); + detailsAndTable.querySelector('.table').appendChild( + individualResults.render(showDetails)); + }); + } + return detailsAndTable; + } + + /** + * @private + * @return {!Element} + */ + renderDetails_() { + const details = OmniboxElement.getTemplate('details-template'); + details.querySelector('.cursor-position').textContent = + this.details.cursorPosition; + details.querySelector('.time').textContent = this.details.time; + details.querySelector('.done').textContent = this.details.done; + details.querySelector('.host').textContent = this.details.host; + details.querySelector('.is-typed-host').textContent = + this.details.isTypedHost; + return details; + } + + /** + * @private + * @param {boolean} showDetails + * @return {boolean} + */ + showAdditionalPropertiesColumn_(showDetails) { + return showDetails && + (this.combinedResults.hasAdditionalProperties || + this.individualResultsList.some( + results => results.hasAdditionalProperties)); + } + + /** @return {!Array<!OutputMatch>} */ + get matches() { + return [this.combinedResults] + .concat(this.individualResultsList) + .flatMap(results => results.matches); + } + } + + /** + * Helps track and render a list of results. Each result is tracked and + * rendered by OutputMatch below. + */ + class OutputResultsTable { + /** @param {!Array<!mojom.AutocompleteMatch>} results */ + constructor(results) { + /** @type {!Array<!OutputMatch>} */ + this.matches = results.map(match => new OutputMatch(match)); + } + + /** + * @param {boolean} showDetails + * @param {boolean} showAdditionalPropertiesColumn + * @return {Element} + */ + static renderHeader(showDetails, showAdditionalPropertiesColumn) { + const head = document.createElement('thead'); + const row = document.createElement('tr'); + const cells = + OutputMatch.displayedProperties(showDetails) + .map( + ({header, url, tooltip}) => + OutputMatch.renderHeaderCell(header, url, tooltip)); + if (showAdditionalPropertiesColumn) + cells.push(OutputMatch.renderHeaderCell('Additional Properties')); + cells.forEach(cell => row.appendChild(cell)); + head.appendChild(row); + return head; + } + + /** + * Creates a HTML Node representing this data. + * @param {boolean} showDetails + * @return {!Element} + */ + render(showDetails) { + const body = document.createElement('tbody'); + this.matches.forEach( + match => body.appendChild(match.render(showDetails))); + return body; + } + + /** + * @param {boolean} showDetails + * @param {boolean} showAdditionalPropertiesColumn + * @return {!Element} + */ + renderInnerHeader(showDetails, showAdditionalPropertiesColumn) { + const head = document.createElement('thead'); + const row = document.createElement('tr'); + const cell = document.createElement('th'); + // Reserve 1 more column if showing the additional properties column. + cell.colSpan = OutputMatch.displayedProperties(showDetails).length + + showAdditionalPropertiesColumn; + cell.textContent = this.matches[0].properties.providerName.value; + row.appendChild(cell); + head.appendChild(row); + return head; + } + + /** @return {boolean} */ + get hasAdditionalProperties() { + return this.matches.some(match => match.hasAdditionalProperties); + } + } + + /** Helps track and render a single match. */ + class OutputMatch { + /** @param {!mojom.AutocompleteMatch} match */ + constructor(match) { + /** @dict */ + this.properties = {}; + let unconsumedProperties = {}; + Object.entries(match).forEach(propertyNameValueTuple => { + // TODO(manukh) replace with destructuring when the styleguide is + // updated + // https://chromium-review.googlesource.com/c/chromium/src/+/1271915 + const propertyName = propertyNameValueTuple[0]; + const propertyValue = propertyNameValueTuple[1]; + + if (PROPERTY_OUTPUT_ORDER.some( + property => property.propertyName === propertyName)) { + this.properties[propertyName] = + OutputProperty.constructProperty(propertyName, propertyValue); + } else { + unconsumedProperties[propertyName] = propertyValue; + } + }); + /** @type {!OutputProperty} */ + this.additionalProperties = OutputProperty.constructProperty( + 'additionalProperties', unconsumedProperties); + + /** @type {!Element} */ + this.associatedElement; + } + + /** + * Creates a HTML Node representing this data. + * @param {boolean} showDetails + * @return {!Element} + */ + render(showDetails) { + const row = document.createElement('tr'); + OutputMatch.displayedProperties(showDetails) + .map(property => this.properties[property.propertyName].render()) + .forEach(cell => row.appendChild(cell)); + + if (showDetails && this.hasAdditionalProperties) + row.appendChild(this.additionalProperties.render()); + + this.associatedElement = row; + return this.associatedElement; + } + + /** + * @param {string} name + * @param {string=} url + * @param {string=} tooltip + * @return {!Element} + */ + static renderHeaderCell(name, url, tooltip) { + const cell = document.createElement('th'); + if (url) { + const link = document.createElement('a'); + link.textContent = name; + link.href = url; + cell.appendChild(link); + } else { + cell.textContent = name; + } + cell.className = + 'column-' + name.replace(/[A-Z]/g, c => '-' + c.toLowerCase()); + cell.title = tooltip || ''; + return cell; + } + + /** + * @return {!Array<!PresentationInfoRecord>} Array representing which + * columns need to be displayed. + */ + static displayedProperties(showDetails) { + return showDetails ? + PROPERTY_OUTPUT_ORDER : + PROPERTY_OUTPUT_ORDER.filter(property => property.displayAlways); + } + + /** + * @return {boolean} Used to determine if the additional properties column + * needs to be displayed for this match. + */ + get hasAdditionalProperties() { + return Object.keys(this.additionalProperties).length > 0; + } + } + + /** @abstract */ + class OutputProperty { + /** + * @param {string} name + * @param {*} value + */ + constructor(name, value) { + /** @type {string} */ + this.name = name; + /** @type {*} */ + this.value = value; + } + + /** + * @param {string} name + * @param {*} value + * @return {!OutputProperty} + */ + static constructProperty(name, value) { + if (typeof value === 'boolean') + return new OutputBooleanProperty(name, value); + if (typeof value === 'object') + // We check if the first element has key and value properties. + if (value && value[0] && value[0].key && value[0].value) + return new OutputKeyValueTuplesProperty(name, value); + else + return new OutputJsonProperty(name, value); + const LINK_REGEX = /^(http|https|ftp|chrome|file):\/\//; + if (LINK_REGEX.test(value)) + return new OutputLinkProperty(name, value); + return new OutputTextProperty(name, value); + } + + /** + * @abstract + * @return {!Element} + */ + render() {} + + /** @return {string} */ + get text() { + return this.value + ''; + } + } + + class OutputBooleanProperty extends OutputProperty { + /** + * @override + * @return {!Element} + */ + render() { + const cell = document.createElement('td'); + const icon = document.createElement('div'); + icon.className = this.value ? 'check-mark' : 'x-mark'; + icon.textContent = this.value; + cell.appendChild(icon); + return cell; + } + } + + class OutputKeyValueTuplesProperty extends OutputProperty { + /** + * @override + * @return {!Element} + */ + render() { + const cell = document.createElement('td'); + const pre = document.createElement('pre'); + pre.textContent = this.text; + cell.appendChild(pre); + return cell; + } + + /** + * @override + * @return {string} + */ + get text() { + return this.value.reduce( + (prev, {key, value}) => `${prev}${key}: ${value}\n`, ''); + } + } + + class OutputJsonProperty extends OutputProperty { + /** + * @override + * @return {!Element} + */ + render() { + const cell = document.createElement('td'); + const pre = document.createElement('pre'); + pre.textContent = this.text; + cell.appendChild(pre); + return cell; + } + + /** + * @override + * @return {string} + */ + get text() { + return JSON.stringify(this.value, null, 2); + } + } + + class OutputLinkProperty extends OutputProperty { + /** + * @override + * @return {!Element} + */ + render() { + const cell = document.createElement('td'); + const link = document.createElement('a'); + link.textContent = this.value; + link.href = this.value; + cell.appendChild(link); + return cell; + } + } + + class OutputTextProperty extends OutputProperty { + /** + * @override + * @return {!Element} + */ + render() { + const cell = document.createElement('td'); + cell.textContent = this.value; + return cell; + } + } + + /** Responsible for setting clipboard contents. */ + class CopyDelegate { + /** @param {!omnibox_output.OmniboxOutput} omniboxOutput */ + constructor(omniboxOutput) { + /** @private {!omnibox_output.OmniboxOutput} */ + this.omniboxOutput_ = omniboxOutput; + } + + copyTextOutput() { + this.copy_(this.omniboxOutput_.renderDelegate.visibletableText); + } + + copyJsonOutput() { + this.copy_(JSON.stringify(this.omniboxOutput_.responses, null, 2)); + } + + /** + * @private + * @param {string} value + */ + copy_(value) { + navigator.clipboard.writeText(value).catch( + error => console.error('unable to copy to clipboard:', error)); + } + } + + /** Responsible for highlighting and hiding rows using filter text. */ + class FilterDelegate { + /** @param {!omnibox_output.OmniboxOutput} omniboxOutput */ + constructor(omniboxOutput) { + /** @private {!omnibox_output.OmniboxOutput} */ + this.omniboxOutput_ = omniboxOutput; + } + + /** + * @param {string} filterText + * @param {boolean} filterHide + */ + filter(filterText, filterHide) { + this.omniboxOutput_.matches.filter(match => match.associatedElement) + .forEach(match => { + const row = match.associatedElement; + row.classList.remove('filtered-hidden'); + row.classList.remove('filtered-highlighted'); + + if (!filterText) + return; + + const isMatch = FilterDelegate.filterMatch_(match, filterText); + row.classList.toggle('filtered-hidden', filterHide && !isMatch); + row.classList.toggle( + 'filtered-highlighted', !filterHide && isMatch); + }); + } + + /** + * Checks if a omnibox match fuzzy-matches a filter string. Each character + * of filterText must be present in the match text, either adjacent to the + * previous matched character, or at the start of a new word (see + * textToWords_). + * E.g. `abc` matches `abc`, `a big cat`, `a-bigCat`, `a very big cat`, and + * `an amBer cat`; but does not match `abigcat` or `an amber cat`. + * `green rainbow` is matched by `gre rain`, but not by `gre bow`. + * One exception is the first character, which may be matched mid-word. + * E.g. `een rain` can also match `green rainbow`. + * @private + * @param {!OutputMatch} match + * @param {string} filterText + * @return {boolean} + */ + static filterMatch_(match, filterText) { + const row = match.associatedElement; + const cells = Array.from(row.querySelectorAll('td')); + const regexFilter = Array.from(filterText).join('(.*\\.)?'); + return cells + .map(cell => FilterDelegate.textToWords_(cell.textContent).join('.')) + .some(text => text.match(regexFilter)); + } + + /** + * Splits a string into words, delimited by either capital letters, groups + * of digits, or non alpha characters. + * E.g., `https://google.com/the-dog-ate-134pies` will be split to: + * https, :, /, /, google, ., com, /, the, -, dog, -, ate, -, 134, pies + * We don't use `Array.split`, because we want to group digits, e.g. 134. + * @private + * @param {string} text + * @return {!Array<string>} + */ + static textToWords_(text) { + return text.match(/[a-z]+|[A-Z][a-z]*|\d+|./g) || []; + } + } + + window.customElements.define(OmniboxOutput.is, OmniboxOutput); + + return {OmniboxOutput: OmniboxOutput}; +}); |