blob: e42d438db673b5eba34449e72443c5c933d089f1 [file] [log] [blame]
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/cr_components/omnibox/realbox_dropdown.js';
import 'chrome://resources/cr_components/omnibox/realbox_icon.js';
import {AutocompleteMatch, AutocompleteResult, NavigationPredictor, PageCallbackRouter, PageHandlerInterface, SideType} from 'chrome://resources/cr_components/omnibox/omnibox.mojom-webui.js';
import {RealboxBrowserProxy} from 'chrome://resources/cr_components/omnibox/realbox_browser_proxy.js';
import {RealboxDropdownElement} from 'chrome://resources/cr_components/omnibox/realbox_dropdown.js';
import {RealboxIconElement} from 'chrome://resources/cr_components/omnibox/realbox_icon.js';
import {assert} from 'chrome://resources/js/assert_ts.js';
import {MetricsReporterImpl} from 'chrome://resources/js/metrics_reporter/metrics_reporter.js';
import {hasKeyModifiers} from 'chrome://resources/js/util_ts.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {loadTimeData} from '../i18n_setup.js';
import {decodeString16, mojoString16} from '../utils.js';
import {getTemplate} from './realbox.html.js';
// 900px ~= 561px (max value for --ntp-search-box-width) * 1.5 + some margin.
const canShowSecondarySideMediaQueryList =
window.matchMedia('(min-width: 900px)');
interface Input {
text: string;
inline: string;
}
interface InputUpdate {
text?: string;
inline?: string;
moveCursorToEnd?: boolean;
}
export interface RealboxElement {
$: {
icon: RealboxIconElement,
input: HTMLInputElement,
inputWrapper: HTMLElement,
matches: RealboxDropdownElement,
voiceSearchButton: HTMLElement,
};
}
/** A real search box that behaves just like the Omnibox. */
export class RealboxElement extends PolymerElement {
static get is() {
return 'ntp-realbox';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
//========================================================================
// Public properties
//========================================================================
/**
* Whether the secondary side can be shown based on the feature state and
* the width available to the dropdown.
*/
canShowSecondarySide: {
type: Boolean,
value: () => canShowSecondarySideMediaQueryList.matches,
reflectToAttribute: true,
},
/** Whether the cr-realbox-dropdown should be visible. */
dropdownIsVisible: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
/**
* Whether the secondary side was at any point available to be shown.
*/
hadSecondarySide: {
type: Boolean,
reflectToAttribute: true,
},
/*
* Whether the secondary side is currently available to be shown.
*/
hasSecondarySide: {
type: Boolean,
reflectToAttribute: true,
},
/** Whether the theme is dark. */
isDark: {
type: Boolean,
reflectToAttribute: true,
},
/** Whether the realbox should match the searchbox. */
matchSearchbox: {
type: Boolean,
value: () => loadTimeData.getBoolean('realboxMatchSearchboxTheme'),
reflectToAttribute: true,
},
/** Whether the Google Lens icon should be visible in the searchbox. */
realboxLensSearchEnabled: {
type: Boolean,
value: () => loadTimeData.getBoolean('realboxLensSearch'),
reflectToAttribute: true,
},
/** Whether to display single-colored icons or not. */
singleColoredIcons: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
//========================================================================
// Private properties
//========================================================================
/**
* Whether user is deleting text in the input. Used to prevent the default
* match from offering inline autocompletion.
*/
isDeletingInput_: {
type: Boolean,
value: false,
},
/**
* The 'Enter' keydown event that was ignored due to matches being stale.
* Used to navigate to the default match once up-to-date matches arrive.
*/
lastIgnoredEnterEvent_: {
type: Object,
value: null,
},
/**
* Last state of the input (text and inline autocompletion). Updated
* by the user input or by the currently selected autocomplete match.
*/
lastInput_: {
type: Object,
value: {text: '', inline: ''},
},
/** The last queried input text. */
lastQueriedInput_: {
type: String,
value: null,
},
/**
* True if user just pasted into the input. Used to prevent the default
* match from offering inline autocompletion.
*/
pastedInInput_: {
type: Boolean,
value: false,
},
/** Realbox default icon (i.e., Google G icon or the search loupe). */
realboxIcon_: {
type: String,
value: () => loadTimeData.getString('realboxDefaultIcon'),
},
/**
* Whether the Google Lens icon should be visible in the searchbox.
*/
realboxLensSearchEnabled_: {
type: Boolean,
value: () => loadTimeData.getBoolean('realboxLensSearch'),
reflectToAttribute: true,
},
result_: {
type: Object,
},
/** The currently selected match, if any. */
selectedMatch_: {
type: Object,
computed: `computeSelectedMatch_(result_, selectedMatchIndex_)`,
},
/**
* Index of the currently selected match, if any.
* Do not modify this. Use <cr-realbox-dropdown> API to change selection.
*/
selectedMatchIndex_: {
type: Number,
value: -1,
},
/** The value of the input element's 'aria-live' attribute. */
inputAriaLive_: {
type: String,
computed: `computeInputAriaLive_(selectedMatch_)`,
},
widthBehavior_: {
type: String,
value: loadTimeData.getString('realboxWidthBehavior'),
reflectToAttribute: true,
},
isTall_: {
type: Boolean,
value: loadTimeData.getBoolean('realboxIsTall'),
reflectToAttribute: true,
},
};
}
canShowSecondarySide: boolean;
dropdownIsVisible: boolean;
hadSecondarySide: boolean;
hasSecondarySide: boolean;
isDark: boolean;
matchSearchbox: boolean;
realboxLensSearchEnabled: boolean;
singleColoredIcons: boolean;
private inputAriaLive_: string;
private isDeletingInput_: boolean;
private lastIgnoredEnterEvent_: KeyboardEvent|null;
private lastInput_: Input;
private lastQueriedInput_: string|null;
private pastedInInput_: boolean;
private realboxIcon_: string;
private realboxLensSearchEnabled_: boolean;
private result_: AutocompleteResult|null;
private selectedMatch_: AutocompleteMatch|null;
private selectedMatchIndex_: number;
private pageHandler_: PageHandlerInterface;
private callbackRouter_: PageCallbackRouter;
private autocompleteResultChangedListenerId_: number|null = null;
constructor() {
performance.mark('realbox-creation-start');
super();
this.pageHandler_ = RealboxBrowserProxy.getInstance().handler;
this.callbackRouter_ = RealboxBrowserProxy.getInstance().callbackRouter;
}
private computeInputAriaLive_(): string {
return this.selectedMatch_ ? 'off' : 'polite';
}
override connectedCallback() {
super.connectedCallback();
this.autocompleteResultChangedListenerId_ =
this.callbackRouter_.autocompleteResultChanged.addListener(
this.onAutocompleteResultChanged_.bind(this));
canShowSecondarySideMediaQueryList.addEventListener(
'change', this.onCanShowSecondarySideChanged_.bind(this));
}
override disconnectedCallback() {
super.disconnectedCallback();
assert(this.autocompleteResultChangedListenerId_);
this.callbackRouter_.removeListener(
this.autocompleteResultChangedListenerId_);
canShowSecondarySideMediaQueryList.removeEventListener(
'change', this.onCanShowSecondarySideChanged_.bind(this));
}
override ready() {
super.ready();
performance.measure('realbox-creation', 'realbox-creation-start');
}
//============================================================================
// Callbacks
//============================================================================
private onAutocompleteResultChanged_(result: AutocompleteResult) {
if (this.lastQueriedInput_ === null ||
this.lastQueriedInput_.trimStart() !== decodeString16(result.input)) {
return; // Stale result; ignore.
}
this.result_ = result;
const hasMatches = result?.matches?.length > 0;
const hasPrimaryMatches = result?.matches?.some(match => {
const sideType =
result.suggestionGroupsMap[match.suggestionGroupId]?.sideType ||
SideType.kDefaultPrimary;
return sideType === SideType.kDefaultPrimary;
});
this.dropdownIsVisible = hasPrimaryMatches;
this.$.input.focus();
const firstMatch = hasMatches ? this.result_.matches[0] : null;
if (firstMatch && firstMatch.allowedToBeDefaultMatch) {
// Select the default match and update the input.
this.$.matches.selectFirst();
this.updateInput_({
text: this.lastQueriedInput_,
inline: decodeString16(firstMatch.inlineAutocompletion) || '',
});
// Navigate to the default up-to-date match if the user typed and pressed
// 'Enter' too fast.
if (this.lastIgnoredEnterEvent_) {
this.navigateToMatch_(0, this.lastIgnoredEnterEvent_);
this.lastIgnoredEnterEvent_ = null;
}
} else if (
hasMatches && this.selectedMatchIndex_ !== -1 &&
this.selectedMatchIndex_ < this.result_.matches.length) {
// Restore the selection and update the input.
this.$.matches.selectIndex(this.selectedMatchIndex_);
this.updateInput_({
text: decodeString16(this.selectedMatch_!.fillIntoEdit),
inline: '',
moveCursorToEnd: true,
});
} else {
// Remove the selection and update the input.
this.$.matches.unselect();
this.updateInput_({
inline: '',
});
}
}
//============================================================================
// Event handlers
//============================================================================
private onCanShowSecondarySideChanged_(e: MediaQueryListEvent) {
this.canShowSecondarySide = e.matches;
}
private onHeaderFocusin_() {
// The header got focus. Unselect the selected match and clear the input.
assert(this.lastQueriedInput_ === '');
this.$.matches.unselect();
this.updateInput_({text: '', inline: ''});
}
private onInputCutCopy_(e: ClipboardEvent) {
// Only handle cut/copy when input has content and it's all selected.
if (!this.$.input.value || this.$.input.selectionStart !== 0 ||
this.$.input.selectionEnd !== this.$.input.value.length ||
!this.result_ || this.result_.matches.length === 0) {
return;
}
if (this.selectedMatch_ && !this.selectedMatch_.isSearchType) {
e.clipboardData!.setData(
'text/plain', this.selectedMatch_.destinationUrl.url);
e.preventDefault();
if (e.type === 'cut') {
this.updateInput_({text: '', inline: ''});
this.clearAutocompleteMatches_();
}
}
}
private onInputFocus_() {
this.pageHandler_.onFocusChanged(true);
}
private onInputInput_(e: InputEvent) {
const inputValue = this.$.input.value;
const lastInputValue = this.lastInput_.text + this.lastInput_.inline;
if (lastInputValue === inputValue) {
return;
}
this.updateInput_({text: inputValue, inline: ''});
// If a character has been typed, mark 'CharTyped'. Otherwise clear it. If
// 'CharTyped' mark already exists, there's a pending typed character for
// which the results have not been painted yet. In that case, keep the
// earlier mark.
const charTyped = !this.isDeletingInput_ && !!inputValue.trim();
const metricsReporter = MetricsReporterImpl.getInstance();
if (charTyped) {
if (!metricsReporter.hasLocalMark('CharTyped')) {
metricsReporter.mark('CharTyped');
}
} else {
metricsReporter.clearMark('CharTyped');
}
if (inputValue.trim()) {
// TODO(crbug.com/1149769): Rather than disabling inline autocompletion
// when the input event is fired within a composition session, change the
// mechanism via which inline autocompletion is shown in the realbox.
this.queryAutocomplete_(inputValue, e.isComposing);
} else {
this.clearAutocompleteMatches_();
}
this.pastedInInput_ = false;
}
private onInputKeydown_(e: KeyboardEvent) {
// Ignore this event if the input does not have any inline autocompletion.
if (!this.lastInput_.inline) {
return;
}
const inputValue = this.$.input.value;
const inputSelection = inputValue.substring(
this.$.input.selectionStart!, this.$.input.selectionEnd!);
const lastInputValue = this.lastInput_.text + this.lastInput_.inline;
// If the current input state (its value and selection) matches its last
// state (text and inline autocompletion) and the user types the next
// character in the inline autocompletion, stop the keydown event. Just move
// the selection and requery autocomplete. This is needed to avoid flicker.
if (inputSelection === this.lastInput_.inline &&
inputValue === lastInputValue &&
this.lastInput_.inline[0].toLocaleLowerCase() ===
e.key.toLocaleLowerCase()) {
const text = this.lastInput_.text + e.key;
assert(text);
this.updateInput_({
text: text,
inline: this.lastInput_.inline.substr(1),
});
// If 'CharTyped' mark already exists, there's a pending typed character
// for which the results have not been painted yet. In that case, keep the
// earlier mark.
const metricsReporter = MetricsReporterImpl.getInstance();
if (!metricsReporter.hasLocalMark('CharTyped')) {
metricsReporter.mark('CharTyped');
}
this.queryAutocomplete_(this.lastInput_.text);
e.preventDefault();
}
}
private onInputKeyup_(e: KeyboardEvent) {
if (e.key !== 'Tab') {
return;
}
// Query for zero-prefix matches if user is tabbing into an empty input and
// matches are not visible.
if (!this.$.input.value && !this.dropdownIsVisible) {
this.queryAutocomplete_('');
}
}
private onInputMouseDown_(e: MouseEvent) {
if (e.button !== 0) {
return;
}
// Query for zero-prefix matches when the main (generally left) mouse button
// is pressed on an empty input and matches are not visible.
if (!this.$.input.value && !this.dropdownIsVisible) {
this.queryAutocomplete_('');
}
}
private onInputPaste_() {
this.pastedInInput_ = true;
}
private onInputWrapperFocusout_(e: FocusEvent) {
// Hide the matches and stop autocomplete only when the focus goes outside
// of the realbox wrapper.
if (!this.$.inputWrapper.contains(e.relatedTarget as Element)) {
if (this.lastQueriedInput_ === '') {
// Clear the input as well as the matches if the input was empty when
// the matches arrived.
this.updateInput_({text: '', inline: ''});
this.clearAutocompleteMatches_();
} else {
this.dropdownIsVisible = false;
// Stop autocomplete but leave (potentially stale) results and continue
// listening for key presses. These stale results should never be shown.
// They correspond to the potentially stale suggestion left in the
// realbox when blurred. That stale result may be navigated to by
// focusing and pressing 'Enter'.
this.pageHandler_.stopAutocomplete(/*clearResult=*/ false);
}
this.pageHandler_.onFocusChanged(false);
}
}
private onInputWrapperKeydown_(e: KeyboardEvent) {
const KEYDOWN_HANDLED_KEYS = [
'ArrowDown',
'ArrowUp',
'Delete',
'Enter',
'Escape',
'PageDown',
'PageUp',
];
if (!KEYDOWN_HANDLED_KEYS.includes(e.key)) {
return;
}
if (e.defaultPrevented) {
// Ignore previously handled events.
return;
}
// ArrowUp/ArrowDown query autocomplete when matches are not visible.
if (!this.dropdownIsVisible) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const inputValue = this.$.input.value;
if (inputValue.trim() || !inputValue) {
this.queryAutocomplete_(inputValue);
}
e.preventDefault();
return;
}
}
// Do not handle the following keys if there are no matches available.
if (!this.result_ || this.result_.matches.length === 0) {
return;
}
if (e.key === 'Delete') {
if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
if (this.selectedMatch_ && this.selectedMatch_.supportsDeletion) {
this.pageHandler_.deleteAutocompleteMatch(
this.selectedMatchIndex_, this.selectedMatch_.destinationUrl);
e.preventDefault();
}
}
return;
}
// Do not handle the following keys if inside an IME composition session.
if (e.isComposing) {
return;
}
if (e.key === 'Enter') {
const array: HTMLElement[] = [this.$.matches, this.$.input];
if (array.includes(e.target as HTMLElement)) {
if (this.lastQueriedInput_ !== null &&
this.lastQueriedInput_.trimStart() ===
decodeString16(this.result_.input)) {
if (this.selectedMatch_) {
this.navigateToMatch_(this.selectedMatchIndex_, e);
}
} else {
// User typed and pressed 'Enter' too quickly. Ignore this for now
// because the matches are stale. Navigate to the default match (if
// one exists) once the up-to-date matches arrive.
this.lastIgnoredEnterEvent_ = e;
e.preventDefault();
}
}
return;
}
// Do not handle the following keys if there are key modifiers.
if (hasKeyModifiers(e)) {
return;
}
// Clear the input as well as the matches when 'Escape' is pressed if the
// the first match is selected or there are no selected matches.
if (e.key === 'Escape' && this.selectedMatchIndex_ <= 0) {
this.updateInput_({text: '', inline: ''});
this.clearAutocompleteMatches_();
e.preventDefault();
return;
}
if (e.key === 'ArrowDown') {
this.$.matches.selectNext();
this.pageHandler_.onNavigationLikely(
this.selectedMatchIndex_, this.selectedMatch_!.destinationUrl,
NavigationPredictor.kUpOrDownArrowButton);
} else if (e.key === 'ArrowUp') {
this.$.matches.selectPrevious();
this.pageHandler_.onNavigationLikely(
this.selectedMatchIndex_, this.selectedMatch_!.destinationUrl,
NavigationPredictor.kUpOrDownArrowButton);
} else if (e.key === 'Escape' || e.key === 'PageUp') {
this.$.matches.selectFirst();
} else if (e.key === 'PageDown') {
this.$.matches.selectLast();
}
e.preventDefault();
// Focus the selected match if focus is currently in the matches.
if (this.shadowRoot!.activeElement === this.$.matches) {
this.$.matches.focusSelected();
}
// Update the input.
const newFill = decodeString16(this.selectedMatch_!.fillIntoEdit);
const newInline = this.selectedMatchIndex_ === 0 &&
this.selectedMatch_!.allowedToBeDefaultMatch ?
decodeString16(this.selectedMatch_!.inlineAutocompletion) :
'';
const newFillEnd = newFill.length - newInline.length;
const text = newFill.substr(0, newFillEnd);
assert(text);
this.updateInput_({
text: text,
inline: newInline,
moveCursorToEnd: newInline.length === 0,
});
}
/**
* @param e Event containing index of the match that was clicked.
*/
private onMatchClick_(e: CustomEvent<{index: number, event: MouseEvent}>) {
this.navigateToMatch_(e.detail.index, e.detail.event);
}
/**
* @param e Event containing index of the match that received focus.
*/
private onMatchFocusin_(e: CustomEvent<number>) {
// Select the match that received focus.
this.$.matches.selectIndex(e.detail);
// Input selection (if any) likely drops due to focus change. Simply fill
// the input with the match and move the cursor to the end.
this.updateInput_({
text: decodeString16(this.selectedMatch_!.fillIntoEdit),
inline: '',
moveCursorToEnd: true,
});
}
/**
* @param e Event containing index of the match that was removed.
*/
private onMatchRemove_(e: CustomEvent<number>) {
const index = e.detail;
const match = this.result_!.matches[index];
assert(match);
this.pageHandler_.deleteAutocompleteMatch(index, match.destinationUrl);
}
private onVoiceSearchClick_() {
this.dispatchEvent(new Event('open-voice-search'));
}
private onLensSearchClick_() {
this.dropdownIsVisible = false;
this.dispatchEvent(new Event('open-lens-search'));
}
//============================================================================
// Helpers
//============================================================================
private computeSelectedMatch_(): AutocompleteMatch|null {
if (!this.result_ || !this.result_.matches) {
return null;
}
return this.result_.matches[this.selectedMatchIndex_] || null;
}
/**
* Clears the autocomplete result on the page and on the autocomplete backend.
*/
private clearAutocompleteMatches_() {
this.dropdownIsVisible = false;
this.result_ = null;
this.$.matches.unselect();
this.pageHandler_.stopAutocomplete(/*clearResult=*/ true);
// Autocomplete sends updates once it is stopped. Invalidate those results
// by setting the |this.lastQueriedInput_| to its default value.
this.lastQueriedInput_ = null;
}
private navigateToMatch_(matchIndex: number, e: KeyboardEvent|MouseEvent) {
assert(matchIndex >= 0);
const match = this.result_!.matches[matchIndex];
assert(match);
this.pageHandler_.openAutocompleteMatch(
matchIndex, match.destinationUrl, this.dropdownIsVisible,
(e as MouseEvent).button || 0, e.altKey, e.ctrlKey, e.metaKey,
e.shiftKey);
e.preventDefault();
}
private queryAutocomplete_(
input: string, preventInlineAutocomplete: boolean = false) {
this.lastQueriedInput_ = input;
const caretNotAtEnd = this.$.input.selectionStart !== input.length;
preventInlineAutocomplete = preventInlineAutocomplete ||
this.isDeletingInput_ || this.pastedInInput_ || caretNotAtEnd;
this.pageHandler_.queryAutocomplete(
mojoString16(input), preventInlineAutocomplete);
}
/**
* Updates the input state (text and inline autocompletion) with |update|.
*/
private updateInput_(update: InputUpdate) {
const newInput = Object.assign({}, this.lastInput_, update);
const newInputValue = newInput.text + newInput.inline;
const lastInputValue = this.lastInput_.text + this.lastInput_.inline;
const inlineDiffers = newInput.inline !== this.lastInput_.inline;
const preserveSelection = !inlineDiffers && !update.moveCursorToEnd;
let needsSelectionUpdate = !preserveSelection;
const oldSelectionStart = this.$.input.selectionStart;
const oldSelectionEnd = this.$.input.selectionEnd;
if (newInputValue !== this.$.input.value) {
this.$.input.value = newInputValue;
needsSelectionUpdate = true; // Setting .value blows away selection.
}
if (newInputValue.trim() && needsSelectionUpdate) {
// If the cursor is to be moved to the end (implies selection should not
// be perserved), set the selection start to same as the selection end.
this.$.input.selectionStart = preserveSelection ?
oldSelectionStart :
update.moveCursorToEnd ? newInputValue.length : newInput.text.length;
this.$.input.selectionEnd =
preserveSelection ? oldSelectionEnd : newInputValue.length;
}
this.isDeletingInput_ = lastInputValue.length > newInputValue.length &&
lastInputValue.startsWith(newInputValue);
this.lastInput_ = newInput;
}
}
declare global {
interface HTMLElementTagNameMap {
'ntp-realbox': RealboxElement;
}
}
customElements.define(RealboxElement.is, RealboxElement);