blob: 5c22c24b62545990bcf37cc750fa717a939cdc94 [file] [log] [blame]
// 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: '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: 'Description',
url: '',
propertyName: 'description',
displayAlways: false,
tooltip: 'The page title of the result.'
},
{
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 (TODO)
* 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 {!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_);
}
}
// Responsible for rendering the output HTML.
class RenderDelegate {
/** @param {Element} containerElement */
constructor(containerElement) {
this.containerElement = containerElement;
}
/**
* @param {QueryInputs} queryInputs
* @param {!Array<!mojom.OmniboxResult>} responses
* @param {DisplayInputs} displayInputs
*/
refresh(queryInputs, responses, displayInputs) {
this.clearOutput_();
if (responses.length) {
if (displayInputs.showIncompleteResults) {
responses.forEach(
response => this.addOutputResultsGroup_(
response, queryInputs, displayInputs));
} else {
this.addOutputResultsGroup_(
responses[responses.length - 1], queryInputs, displayInputs);
}
}
}
/**
* @private
* @param {!mojom.OmniboxResult} response
* @param {QueryInputs} queryInputs
* @param {DisplayInputs} displayInputs
*/
addOutputResultsGroup_(response, queryInputs, displayInputs) {
this.containerElement.appendChild(
new OutputResultsGroup(response, queryInputs.cursorPosition)
.render(
displayInputs.showDetails,
displayInputs.showIncompleteResults,
displayInputs.showAllProviders));
}
/** @private */
clearOutput_() {
let contents = this.containerElement;
// Clears all children.
while (contents.firstChild)
contents.removeChild(contents.firstChild);
}
/** @return {string} */
get visibletableText() {
return this.containerElement.innerText;
}
}
/**
* 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 resultsGroupNode =
OmniboxElement.getTemplate('results-group-template');
if (showDetails || showIncompleteResults) {
resultsGroupNode.querySelector('.details')
.appendChild(this.renderDetails_());
}
resultsGroupNode.querySelector('.combined-results')
.appendChild(this.combinedResults.render(showDetails));
if (showAllProviders) {
resultsGroupNode.querySelector('.individual-results')
.appendChild(this.renderIndividualResults_(showDetails));
}
return resultsGroupNode;
}
/**
* @private
* @return {Element}
*/
renderDetails_() {
const details =
OmniboxElement.getTemplate('results-group-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 {Element}
*/
renderIndividualResults_(showDetails) {
const individualResultsNode = OmniboxElement.getTemplate(
'results-group-individual-results-template');
this.individualResultsList.forEach(
individualResults => individualResultsNode.appendChild(
individualResults.render(showDetails)));
return individualResultsNode;
}
}
/**
* 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));
}
/**
* Creates a HTML Node representing this data.
* @param {boolean} showDetails
* @return {Element}
*/
render(showDetails) {
const resultsTable = OmniboxElement.getTemplate('results-table-template');
// The additional properties column only needs be displayed if at least
// one of the results have additional properties.
const showAdditionalPropertiesHeader = this.matches.some(
match => match.showAdditionalProperties(showDetails));
resultsTable.querySelector('.results-table-body')
.appendChild(OutputMatch.renderHeader_(
showDetails, showAdditionalPropertiesHeader));
this.matches.forEach(
match => resultsTable.querySelector('.results-table-body')
.appendChild(match.render(showDetails)));
return resultsTable;
}
}
/** Helps track and render a single match. */
class OutputMatch {
/** @param {!mojom.AutocompleteMatch} match */
constructor(match) {
/** @dict */
this.properties = {};
/** @dict */
this.additionalProperties = {};
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(
displayProperty =>
displayProperty.propertyName === propertyName)) {
this.properties[propertyName] = propertyValue;
} else {
this.additionalProperties[propertyName] = propertyValue;
}
});
}
/**
* Creates a HTML Node representing this data.
* @param {boolean} showDetails
* @return {Element}
*/
render(showDetails) {
const row = document.createElement('tr');
OutputMatch.displayedProperties(showDetails)
.map(property => {
const value = this.properties[property.propertyName];
if (typeof value === 'object')
return OutputMatch.renderJsonProperty_(value);
if (typeof value === 'boolean')
return OutputMatch.renderBooleanProperty_(value);
const LINK_REGEX = /^(http|https|ftp|chrome|file):\/\//;
if (LINK_REGEX.test(value))
return OutputMatch.renderLinkProperty_(value);
return OutputMatch.renderTextProperty_(value);
})
.forEach(cell => row.appendChild(cell));
if (this.showAdditionalProperties(showDetails)) {
row.appendChild(
OutputMatch.renderJsonProperty_(this.additionalProperties));
}
return row;
}
/**
* TODO(manukh) replace these static render_ functions with subclasses when
* rendering becomes more substantial
* @private
* @param {string} propertyValue
* @return {Element}
*/
static renderTextProperty_(propertyValue) {
const cell = document.createElement('td');
cell.textContent = propertyValue;
return cell;
}
/**
* @private
* @param {Object} propertyValue
* @return {Element}
*/
static renderJsonProperty_(propertyValue) {
const cell = document.createElement('td');
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(propertyValue, null, 2);
cell.appendChild(pre);
return cell;
}
/**
* @private
* @param {boolean} propertyValue
* @return {Element}
*/
static renderBooleanProperty_(propertyValue) {
const cell = document.createElement('td');
const icon = document.createElement('div');
icon.className = propertyValue ? 'check-mark' : 'x-mark';
icon.textContent = propertyValue;
cell.appendChild(icon);
return cell;
}
/**
* @private
* @param {string} propertyValue
* @return {Element}
*/
static renderLinkProperty_(propertyValue) {
let cell = document.createElement('td');
let link = document.createElement('a');
link.textContent = propertyValue;
link.href = propertyValue;
cell.appendChild(link);
return cell;
}
/**
* @private
* @param {boolean} showDetails
* @param {boolean} showAdditionalHeader
* @return {Element}
*/
static renderHeader_(showDetails, showAdditionalHeader) {
const row = document.createElement('tr');
const headerCells =
OutputMatch.displayedProperties(showDetails)
.map(
displayProperty => OutputMatch.renderHeaderCell_(
displayProperty.header, displayProperty.url,
displayProperty.tooltip));
if (showAdditionalHeader) {
headerCells.push(
OutputMatch.renderHeaderCell_('Additional Properties'));
}
headerCells.forEach(headerCell => row.appendChild(headerCell));
return row;
}
/**
* @private
* @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.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} True if the additional properties column is required
* to be displayed for this result. False if the column can be hidden
* because this result does not have additional properties.
*/
showAdditionalProperties(showDetails) {
return showDetails && Object.keys(this.additionalProperties).length;
}
}
/** Responsible for setting clipboard contents. */
class CopyDelegate {
/** @param {omnibox_output.OmniboxOutput} omniboxOutput */
constructor(omniboxOutput) {
/** @type {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));
}
}
window.customElements.define(OmniboxOutput.is, OmniboxOutput);
return {OmniboxOutput: OmniboxOutput};
});