blob: 944121a3819703ad0b068cd1ec74b78b77276181 [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: '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;
}
/** @return !Array<!OutputProperty> */
get allProperties() {
return Object.values(this.properties).concat(this.additionalProperties);
}
}
/** @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 regexFilter = Array.from(filterText).join('(.*\\.)?');
return match.allProperties
.map(property => property.text)
.map(text => FilterDelegate.textToWords_(text).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};
});