diff options
Diffstat (limited to 'chromium/chrome/browser/resources/local_ntp/local_ntp.js')
-rw-r--r-- | chromium/chrome/browser/resources/local_ntp/local_ntp.js | 903 |
1 files changed, 782 insertions, 121 deletions
diff --git a/chromium/chrome/browser/resources/local_ntp/local_ntp.js b/chromium/chrome/browser/resources/local_ntp/local_ntp.js index 21ebc4ef20f..50972ac06fb 100644 --- a/chromium/chrome/browser/resources/local_ntp/local_ntp.js +++ b/chromium/chrome/browser/resources/local_ntp/local_ntp.js @@ -31,6 +31,37 @@ let tilesAreLoaded = false; function LocalNTP() { 'use strict'; +// Type definitions. + +/** @enum {number} */ +const ACMatchClassificationStyle = { + NONE: 0, + URL: 1 << 0, + MATCH: 1 << 1, + DIM: 1 << 2, +}; + +/** @enum {number} */ +const AutocompleteResultStatus = { + SUCCESS: 0, + SKIPPED: 1, +}; + +/** @type {string} */ +let lastInput; + +/** @typedef {{inline: string, text: string}} */ +let RealboxOutput; + +/** + * @typedef {{ + * moveCursorToEnd: (boolean|undefined), + * inline: (string|undefined), + * text: (string|undefined), + * }} + */ +let RealboxOutputUpdate; + // Constants. /** @@ -40,16 +71,17 @@ function LocalNTP() { */ const CLASSES = { ALTERNATE_LOGO: 'alternate-logo', // Shows white logo if required by theme + // Shows a clock next to historical realbox results. + CLOCK_ICON: 'clock-icon', // Applies styles to dialogs used in customization. CUSTOMIZE_DIALOG: 'customize-dialog', - DARK: 'dark', - DEFAULT_THEME: 'default-theme', DELAYED_HIDE_NOTIFICATION: 'mv-notice-delayed-hide', + DISMISSABLE: 'dismissable', + DISMISS_ICON: 'dismiss-icon', + DISMISS_PROMO: 'dismiss-promo', // Extended and elevated style for customization entry point. ENTRY_POINT_ENHANCED: 'ep-enhanced', FAKEBOX_FOCUS: 'fakebox-focused', // Applies focus styles to the fakebox - // Applied when the fakebox placeholder text should not be hidden on focus. - SHOW_PLACEHOLDER: 'show-placeholder', // Applies float animations to the Most Visited notification FLOAT_DOWN: 'float-down', FLOAT_UP: 'float-up', @@ -63,20 +95,22 @@ const CLASSES = { LEFT_ALIGN_ATTRIBUTION: 'left-align-attribution', // Vertically centers the most visited section for a non-Google provided page. NON_GOOGLE_PAGE: 'non-google-page', - NON_WHITE_BG: 'non-white-bg', - RTL: 'rtl', // Right-to-left language text. + REMOVABLE: 'removable', + REMOVE_ICON: 'remove-icon', + REMOVE_MATCH: 'remove-match', + SEARCH_ICON: 'search-icon', // Magnifying glass/search icon. + SELECTED: 'selected', // A selected (via up/down arrow key) realbox match. SHOW_ELEMENT: 'show-element', + // When the realbox has matches to show. + SHOW_MATCHES: 'show-matches', // Applied when the doodle notifier should be shown instead of the doodle. USE_NOTIFIER: 'use-notifier', }; -/** - * Background color for Chrome dark mode. Used to determine if it is possible to - * display a Google Doodle, or if the notifier should be used instead. - * @type {string} - * @const - */ -const DARK_MODE_BACKGROUND_COLOR = 'rgba(50,54,57,1)'; +const SEARCH_HISTORY_MATCH_TYPES = [ + 'search-history', + 'search-suggest-personalized', +]; /** * The period of time (ms) before transitions can be applied to a toast @@ -101,7 +135,6 @@ const IDS = { ERROR_NOTIFICATION_LINK: 'error-notice-link', ERROR_NOTIFICATION_MSG: 'error-notice-msg', FAKEBOX: 'fakebox', - FAKEBOX_ICON: 'fakebox-search-icon', FAKEBOX_INPUT: 'fakebox-input', FAKEBOX_TEXT: 'fakebox-text', FAKEBOX_MICROPHONE: 'fakebox-microphone', @@ -112,6 +145,10 @@ const IDS = { NTP_CONTENTS: 'ntp-contents', OGB: 'one-google', PROMO: 'promo', + REALBOX: 'realbox', + REALBOX_INPUT_WRAPPER: 'realbox-input-wrapper', + REALBOX_MATCHES: 'realbox-matches', + REALBOX_MICROPHONE: 'realbox-microphone', RESTORE_ALL_LINK: 'mv-restore', SUGGESTIONS: 'suggestions', TILES: 'mv-tiles', @@ -188,7 +225,7 @@ const NOTIFICATION_TIMEOUT = 10000; */ const NTP_DESIGN = { backgroundColor: [255, 255, 255, 255], - darkBackgroundColor: [50, 54, 57, 255], + darkBackgroundColor: [53, 54, 58, 255], iconBackgroundColor: [241, 243, 244, 255], /** GG100 */ iconDarkBackgroundColor: [32, 33, 36, 255], /** GG900 */ numTitleLines: 1, @@ -196,16 +233,21 @@ const NTP_DESIGN = { titleColorAgainstDark: [248, 249, 250, 255], /** GG050 */ }; -/** - * Background colors considered "white". Used to determine if it is possible to - * display a Google Doodle, or if the notifier should be used instead. Also used - * to determine if a colored or white logo should be used. - * @const - */ -const WHITE_BACKGROUND_COLORS = ['rgba(255,255,255,1)', 'rgba(0,0,0,0)']; +const REALBOX_KEYDOWN_HANDLED_KEYS = [ + 'ArrowDown', + 'ArrowUp', + 'Delete', + 'Enter', + 'Escape', + 'PageDown', + 'PageUp', +]; // Local statics. +/** @type {!Array<!AutocompleteMatch>} */ +let autocompleteMatches = []; + /** * The currently visible notification element. Null if no notification is * present. @@ -226,6 +268,16 @@ let delayedHideNotification = null; */ let isDarkModeEnabled = false; +/** Used to prevent inline autocompleting recently deleted output. */ +let isDeletingInput = false; + +/** + * The rendered autocomplete match currently being deleted, or null if there + * isn't one. + * @type {?Element} + */ +let matchElBeingDeleted = null; + /** * The last blacklisted tile rid if any, which by definition should not be * filler. @@ -234,6 +286,13 @@ let isDarkModeEnabled = false; let lastBlacklistedTile = null; /** + * Last text/inline autocompletion shown in the realbox (either by user input or + * outputting autocomplete matches). + * @type {!RealboxOutput} + */ +let lastOutput = {text: '', inline: ''}; + +/** * The browser embeddedSearch.newTabPage object. * @type {Object} */ @@ -241,6 +300,29 @@ let ntpApiHandle; // Helper methods. +/** @return {boolean} */ +function areRealboxMatchesVisible() { + return $(IDS.REALBOX_INPUT_WRAPPER).classList.contains(CLASSES.SHOW_MATCHES); +} + +/** + * @param {number} style + * @return {!Array<string>} + */ +function classificationStyleToClasses(style) { + const classes = []; + if (style & ACMatchClassificationStyle.DIM) { + classes.push('dim'); + } + if (style & ACMatchClassificationStyle.MATCH) { + classes.push('match'); + } + if (style & ACMatchClassificationStyle.URL) { + classes.push('url'); + } + return classes; +} + /** * Converts an Array of color components into RGBA format "rgba(R,G,B,A)". * @param {Array<number>} color Array of rgba color components. @@ -296,9 +378,6 @@ function createIframes() { if (configData.isGooglePage) { args.push('enableCustomLinks=1'); - if (configData.enableShortcutsGrid) { - args.push('enableGrid=1'); - } args.push( 'addLink=' + encodeURIComponent(configData.translatedStrings.addLinkTitle)); @@ -536,6 +615,12 @@ function getThemeBackgroundInfo() { // backgroundImage is in the form: url("actual url"). Remove everything // except the actual url. info.imageUrl = preview.style.backgroundImage.slice(5, -2); + + if (preview.dataset.hasImage === 'true') { + info.attribution1 = preview.dataset.attributionLine1; + info.attribution2 = preview.dataset.attributionLine2; + info.attributionActionUrl = preview.dataset.attributionActionUrl; + } } return info; } @@ -569,7 +654,7 @@ function handlePostMessage(event) { $(IDS.SUGGESTIONS).style.visibility = 'visible'; } if ($(IDS.PROMO)) { - $(IDS.PROMO).classList.add(CLASSES.SHOW_ELEMENT); + showPromoIfNotOverlappingAndTrackResizes(); } if (customLinksEnabled()) { $(customize.IDS.CUSTOM_LINKS_RESTORE_DEFAULT) @@ -672,70 +757,89 @@ function init() { customize.init(showErrorNotification, hideNotification); - if (configData.showFakeboxPlaceholderOnFocus) { - $(IDS.FAKEBOX_TEXT).classList.add(CLASSES.SHOW_PLACEHOLDER); - } + if (configData.realboxEnabled) { + const realboxEl = $(IDS.REALBOX); + realboxEl.placeholder = configData.translatedStrings.searchboxPlaceholder; + realboxEl.addEventListener('copy', onRealboxCutCopy); + realboxEl.addEventListener('cut', onRealboxCutCopy); + realboxEl.addEventListener('input', onRealboxInput); - // Set up the fakebox (which only exists on the Google NTP). - ntpApiHandle.oninputstart = onInputStart; - ntpApiHandle.oninputcancel = onInputCancel; + const realboxWrapper = $(IDS.REALBOX_INPUT_WRAPPER); + realboxWrapper.addEventListener('focusin', onRealboxWrapperFocusIn); + realboxWrapper.addEventListener('focusout', onRealboxWrapperFocusOut); - if (ntpApiHandle.isInputInProgress) { - onInputStart(); - } + searchboxApiHandle.onqueryautocompletedone = onQueryAutocompleteDone; + searchboxApiHandle.ondeleteautocompletematch = onDeleteAutocompleteMatch; - $(IDS.FAKEBOX_TEXT).textContent = - configData.translatedStrings.searchboxPlaceholder; + if (!iframesAndVoiceSearchDisabledForTesting) { + speech.init( + configData.googleBaseUrl, configData.translatedStrings, + $(IDS.REALBOX_MICROPHONE), searchboxApiHandle); + } - if (!iframesAndVoiceSearchDisabledForTesting) { - speech.init( - configData.googleBaseUrl, configData.translatedStrings, - $(IDS.FAKEBOX_MICROPHONE), searchboxApiHandle); - } + utils.disableOutlineOnMouseClick($(IDS.REALBOX_MICROPHONE)); + } else { + // Set up the fakebox (which only exists on the Google NTP). + ntpApiHandle.oninputstart = onInputStart; + ntpApiHandle.oninputcancel = onInputCancel; - // Listener for updating the key capture state. - document.body.onmousedown = function(event) { - if (isFakeboxClick(event)) { - searchboxApiHandle.startCapturingKeyStrokes(); - } else if (isFakeboxFocused()) { - searchboxApiHandle.stopCapturingKeyStrokes(); + if (ntpApiHandle.isInputInProgress) { + onInputStart(); } - }; - searchboxApiHandle.onkeycapturechange = function() { - setFakeboxFocus(searchboxApiHandle.isKeyCaptureEnabled); - }; - const inputbox = $(IDS.FAKEBOX_INPUT); - inputbox.onpaste = function(event) { - event.preventDefault(); - // Send pasted text to Omnibox. - const text = event.clipboardData.getData('text/plain'); - if (text) { - searchboxApiHandle.paste(text); + + $(IDS.FAKEBOX_TEXT).textContent = + configData.translatedStrings.searchboxPlaceholder; + + if (!iframesAndVoiceSearchDisabledForTesting) { + speech.init( + configData.googleBaseUrl, configData.translatedStrings, + $(IDS.FAKEBOX_MICROPHONE), searchboxApiHandle); } - }; - inputbox.ondrop = function(event) { - event.preventDefault(); - const text = event.dataTransfer.getData('text/plain'); - if (text) { - searchboxApiHandle.paste(text); + + // Listener for updating the key capture state. + document.body.onmousedown = function(event) { + if (isFakeboxClick(event)) { + searchboxApiHandle.startCapturingKeyStrokes(); + } else if (isFakeboxFocused()) { + searchboxApiHandle.stopCapturingKeyStrokes(); + } + }; + searchboxApiHandle.onkeycapturechange = function() { + setFakeboxFocus(searchboxApiHandle.isKeyCaptureEnabled); + }; + const inputbox = $(IDS.FAKEBOX_INPUT); + inputbox.onpaste = function(event) { + event.preventDefault(); + // Send pasted text to Omnibox. + const text = event.clipboardData.getData('text/plain'); + if (text) { + searchboxApiHandle.paste(text); + } + }; + inputbox.ondrop = function(event) { + event.preventDefault(); + const text = event.dataTransfer.getData('text/plain'); + if (text) { + searchboxApiHandle.paste(text); + } + setFakeboxDragFocus(false); + }; + inputbox.ondragenter = function() { + setFakeboxDragFocus(true); + }; + inputbox.ondragleave = function() { + setFakeboxDragFocus(false); + }; + utils.disableOutlineOnMouseClick($(IDS.FAKEBOX_MICROPHONE)); + + // Update the fakebox style to match the current key capturing state. + setFakeboxFocus(searchboxApiHandle.isKeyCaptureEnabled); + // Also tell the browser that we're capturing, otherwise it's possible + // that both fakebox and Omnibox have visible focus at the same time, see + // crbug.com/792850. + if (searchboxApiHandle.isKeyCaptureEnabled) { + searchboxApiHandle.startCapturingKeyStrokes(); } - setFakeboxDragFocus(false); - }; - inputbox.ondragenter = function() { - setFakeboxDragFocus(true); - }; - inputbox.ondragleave = function() { - setFakeboxDragFocus(false); - }; - utils.disableOutlineOnMouseClick($(IDS.FAKEBOX_MICROPHONE)); - - // Update the fakebox style to match the current key capturing state. - setFakeboxFocus(searchboxApiHandle.isKeyCaptureEnabled); - // Also tell the browser that we're capturing, otherwise it's possible - // that both fakebox and Omnibox have visible focus at the same time, see - // crbug.com/792850. - if (searchboxApiHandle.isKeyCaptureEnabled) { - searchboxApiHandle.startCapturingKeyStrokes(); } doodles.init(); @@ -745,13 +849,6 @@ function init() { if (searchboxApiHandle.rtl) { $(IDS.NOTIFICATION).dir = 'rtl'; - // Grabbing the root HTML element. TODO(dbeam): could this just be <html ... - // dir="$i18n{textdirection}"> in the .html file instead? It could result in - // less flicker for RTL users (as HTML/CSS can render before JavaScript has - // the chance to run). - document.documentElement.setAttribute('dir', 'rtl'); - // Add class for setting alignments based on language directionality. - document.documentElement.classList.add(CLASSES.RTL); } if (!iframesAndVoiceSearchDisabledForTesting) { @@ -807,7 +904,10 @@ function injectOneGoogleBar(ogb) { * doesn't block the main page load. */ function injectPromo(promo) { - if (promo.promoHtml == '') { + if (!promo.promoHtml) { + if ($(IDS.PROMO)) { + $(IDS.PROMO).remove(); + } return; } @@ -822,16 +922,35 @@ function injectPromo(promo) { ntpApiHandle.logEvent(LOG_TYPE.NTP_MIDDLE_SLOT_PROMO_SHOWN); - const links = promoContainer.getElementsByTagName('a'); - if (links[0]) { - links[0].onclick = function() { + const link = promoContainer.querySelector('a'); + if (link) { + link.onclick = function() { ntpApiHandle.logEvent(LOG_TYPE.NTP_MIDDLE_SLOT_PROMO_LINK_CLICKED); }; } + if (promo.promoId) { + const icon = document.createElement('button'); + icon.classList.add(CLASSES.DISMISS_ICON); + + icon.title = configData.translatedStrings.dismissPromo; + icon.onclick = e => { + ntpApiHandle.blocklistPromo(promo.promoId); + promoContainer.remove(); + window.removeEventListener('resize', showPromoIfNotOverlapping); + }; + + const dismiss = document.createElement('div'); + dismiss.classList.add(CLASSES.DISMISS_PROMO); + dismiss.appendChild(icon); + + promoContainer.querySelector('div').appendChild(dismiss); + promoContainer.classList.add(CLASSES.DISMISSABLE); + } + // The the MV tiles are already loaded show the promo immediately. if (tilesAreLoaded) { - promoContainer.classList.add(CLASSES.SHOW_ELEMENT); + showPromoIfNotOverlappingAndTrackResizes(); } } @@ -873,6 +992,71 @@ function isFakeboxFocused() { document.body.classList.contains(CLASSES.FAKEBOX_DRAG_FOCUS); } +/** @return {boolean} */ +function isPromoOverlapping() { + const MARGIN = 10; + + /** + * @param {string} id + * @return {DOMRect} + */ + const rect = id => $(id).getBoundingClientRect(); + + const promoRect = $(IDS.PROMO).querySelector('div').getBoundingClientRect(); + + if (promoRect.top - MARGIN <= rect(IDS.USER_CONTENT).bottom) { + return true; + } + + if (window.chrome.embeddedSearch.searchBox.rtl) { + const attributionRect = rect(IDS.ATTRIBUTION); + if (attributionRect.width > 0 && + promoRect.left - MARGIN <= attributionRect.right) { + return true; + } + + const editBgRect = rect(customize.IDS.EDIT_BG); + assert(editBgRect.width > 0); + if (promoRect.left - 2 * MARGIN <= editBgRect.right) { + return true; + } + + const customAttributionsRect = rect(customize.IDS.ATTRIBUTIONS); + if (customAttributionsRect.width > 0 && + promoRect.right + MARGIN >= customAttributionsRect.left) { + return true; + } + } else { + const customAttributionsRect = rect(customize.IDS.ATTRIBUTIONS); + if (customAttributionsRect.width > 0 && + promoRect.left - MARGIN <= customAttributionsRect.right) { + return true; + } + + const editBgRect = rect(customize.IDS.EDIT_BG); + assert(editBgRect.width > 0); + if (promoRect.right + 2 * MARGIN >= editBgRect.left) { + return true; + } + + const attributionEl = $(IDS.ATTRIBUTION); + const attributionRect = attributionEl.getBoundingClientRect(); + if (attributionRect.width > 0) { + const attributionOnLeft = + attributionEl.classList.contains(CLASSES.LEFT_ALIGN_ATTRIBUTION); + if (attributionOnLeft) { + if (promoRect.left - MARGIN <= attributionRect.right) { + return true; + } + } else if (promoRect.right + MARGIN >= attributionRect.left) { + return true; + } + } + } + + return false; +} + /** Binds event listeners. */ function listen() { document.addEventListener('DOMContentLoaded', init); @@ -893,6 +1077,47 @@ function onAddCustomLinkDone(success) { ntpApiHandle.logEvent(LOG_TYPE.NTP_CUSTOMIZE_SHORTCUT_DONE); } +/** @param {!DeleteAutocompleteMatchResult} result */ +function onDeleteAutocompleteMatch(result) { + assert(matchElBeingDeleted); + + if (!result.success) { + matchElBeingDeleted = null; + return; + } + + $(IDS.REALBOX).focus(); + + populateAutocompleteMatches(result.matches); + matchElBeingDeleted = null; + + if (result.matches.length === 0) { + updateRealboxOutput({inline: '', text: ''}); + return; + } + + const firstMatch = autocompleteMatches[0]; + if (firstMatch.allowedToBeDefaultMatch) { + const matchEls = Array.from($(IDS.REALBOX_MATCHES).children); + selectMatchEl(matchEls[0]); + + const fill = firstMatch.fillIntoEdit; + const inline = firstMatch.inlineAutocompletion; + const textEnd = fill.length - inline.length; + updateRealboxOutput({ + moveCursorToEnd: true, + inline: inline, + text: assert(fill.substr(0, textEnd)), + }); + } else { + updateRealboxOutput({ + moveCursorToEnd: true, + inline: '', + text: lastInput, + }); + } +} + /** * Callback for embeddedSearch.newTabPage.ondeletecustomlinkdone. Called when * the custom link was successfully deleted. Shows the "Shortcut deleted" @@ -936,6 +1161,233 @@ function onMostVisitedChange() { reloadTiles(); } +/** @param {!AutocompleteResult} result */ +function onQueryAutocompleteDone(result) { + if (result.status === AutocompleteResultStatus.SKIPPED || + result.input !== lastOutput.text) { + return; // Stale or skipped result; ignore. + } + + populateAutocompleteMatches(result.matches); + + if (result.matches.length === 0) { + return; + } + + if (result.matches[0].allowedToBeDefaultMatch) { + selectMatchEl(assert($(IDS.REALBOX_MATCHES).firstElementChild)); + } + + // If the user is deleting content, don't quickly re-suggest the same + // output. + if (!isDeletingInput) { + const first = result.matches[0]; + if (first.allowedToBeDefaultMatch && first.inlineAutocompletion) { + updateRealboxOutput({inline: first.inlineAutocompletion}); + } + } +} + +/** @param {!Event} e */ +function onRealboxCutCopy(e) { + const realboxEl = $(IDS.REALBOX); + if (!realboxEl.value || realboxEl.selectionStart !== 0 || + realboxEl.selectionEnd !== realboxEl.value.length || + autocompleteMatches.length === 0) { + // Only handle cut/copy when realbox has content and it's all selected. + return; + } + + const matchEls = Array.from($(IDS.REALBOX_MATCHES).children); + const selected = matchEls.findIndex(matchEl => { + return matchEl.classList.contains(CLASSES.SELECTED); + }); + + const selectedMatch = autocompleteMatches[selected]; + if (selectedMatch && !selectedMatch.isSearchType) { + e.clipboardData.setData('text/plain', selectedMatch.destinationUrl); + e.preventDefault(); + if (e.type === 'cut') { + realboxEl.value = ''; + } + } +} + +function onRealboxInput() { + const realboxValue = $(IDS.REALBOX).value; + + updateRealboxOutput({inline: '', text: realboxValue}); + + if (realboxValue.trim()) { + queryAutocomplete(realboxValue); + } else { + setRealboxMatchesVisible(false); + setRealboxWrapperListenForKeydown(false); + setAutocompleteMatches([]); + } +} + +/** @param {Event} e */ +function onRealboxWrapperFocusIn(e) { + if (e.target.matches(`#${IDS.REALBOX}`) && !$(IDS.REALBOX).value) { + queryAutocomplete(''); + } else if (e.target.matches(`#${IDS.REALBOX_MATCHES} *`)) { + let target = e.target; + while (target && target.nodeName !== 'A') { + target = target.parentNode; + } + if (!target) { + return; + } + const selectedIndex = selectMatchEl(target); + // It doesn't really make sense to use fillFromMatch() here as the focus + // change drops the selection (and is probably just noisy to + // screenreaders). + const newFill = autocompleteMatches[selectedIndex].fillIntoEdit; + updateRealboxOutput({moveCursorToEnd: true, inline: '', text: newFill}); + } +} + +/** @param {Event} e */ +function onRealboxWrapperFocusOut(e) { + const target = /** @type {Element} */ (e.target); + if (matchElBeingDeleted && matchElBeingDeleted.contains(target)) { + // When a match is being deleted, the focus gets dropped temporariliy as the + // element is deleted from the DOM. Don't stop autocomplete in those cases. + return; + } + + const relatedTarget = /** @type {Element} */ (e.relatedTarget); + const realboxWrapper = $(IDS.REALBOX_INPUT_WRAPPER); + if (!realboxWrapper.contains(relatedTarget)) { + setRealboxMatchesVisible(false); + // Note: intentionally leaving keydown listening and match data intact. + window.chrome.embeddedSearch.searchBox.stopAutocomplete( + /*clearResult=*/ true); + + // Clear the input if it was empty when displaying the matches. + if (lastInput === '') { + updateRealboxOutput({inline: '', text: ''}); + } + } +} + +/** @param {Event} e */ +function onRealboxWrapperKeydown(e) { + assert(autocompleteMatches.length > 0); + + const key = e.key; + + const realboxEl = $(IDS.REALBOX); + if (e.target === realboxEl && lastOutput.inline) { + const realboxValue = realboxEl.value; + const realboxSelected = realboxValue.substring( + realboxEl.selectionStart, realboxEl.selectionEnd); + // If the current state matches the default text + inline autocompletion + // and the user types the next key in the inline autocompletion, just move + // the selection and requery autocomplete. This is required to avoid flicker + // while setting .value and .selection{Start,End} to keep typing smooth. + if (realboxSelected === lastOutput.inline && + realboxValue === lastOutput.text + lastOutput.inline && + lastOutput.inline[0].toLocaleLowerCase() === key.toLocaleLowerCase()) { + updateRealboxOutput({ + inline: lastOutput.inline.substr(1), + text: assert(lastOutput.text + key), + }); + queryAutocomplete(lastOutput.text); + e.preventDefault(); + return; + } + } + + if (!REALBOX_KEYDOWN_HANDLED_KEYS.includes(key)) { + return; + } + + const realboxMatchesEl = $(IDS.REALBOX_MATCHES); + const matchEls = Array.from(realboxMatchesEl.children); + const selected = matchEls.findIndex(matchEl => { + return matchEl.classList.contains(CLASSES.SELECTED); + }); + + if (key === 'Delete') { + if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { + const selectedMatch = autocompleteMatches[selected]; + if (selectedMatch && selectedMatch.supportsDeletion) { + matchElBeingDeleted = matchEls[selected]; + window.chrome.embeddedSearch.searchBox.deleteAutocompleteMatch( + selected); + e.preventDefault(); + } + } + return; + } + + const hasMods = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; + if (hasMods && key !== 'Enter') { + return; + } + + if (key === 'Enter') { + if (matchEls[selected] && matchEls.concat(realboxEl).includes(e.target)) { + // Note: dispatching a MouseEvent here instead of using e.g. .click() as + // this forwards key modifiers. This enables Shift+Enter to open a match + // in a new window, for example. + matchEls[selected].dispatchEvent(new MouseEvent('click', e)); + e.preventDefault(); + } + return; + } + + if (!areRealboxMatchesVisible()) { + if (key === 'ArrowUp' || key === 'ArrowDown') { + setRealboxMatchesVisible(true); + e.preventDefault(); + } + return; + } + + if (key === 'Escape' && selected === 0) { + updateRealboxOutput({inline: '', text: ''}); + setRealboxMatchesVisible(false); + setRealboxWrapperListenForKeydown(false); + setAutocompleteMatches([]); + e.preventDefault(); + return; + } + + /** @type {number} */ let newSelected; + if (key === 'ArrowDown') { + newSelected = selected + 1 < matchEls.length ? selected + 1 : 0; + } else if (key === 'ArrowUp') { + newSelected = selected - 1 >= 0 ? selected - 1 : matchEls.length - 1; + } else if (key === 'Escape' || key === 'PageUp') { + newSelected = 0; + } else if (key === 'PageDown') { + newSelected = matchEls.length - 1; + } + assert(selectMatchEl(assert(matchEls[newSelected])) >= 0); + e.preventDefault(); + + if (realboxMatchesEl.contains(document.activeElement)) { + // Selection should match focus if focus is currently in the matches. + matchEls[newSelected].focus(); + } + + const newMatch = autocompleteMatches[newSelected]; + const newFill = newMatch.fillIntoEdit; + let newInline = ''; + if (newMatch.allowedToBeDefaultMatch) { + newInline = newMatch.inlineAutocompletion; + } + const newFillEnd = newFill.length - newInline.length; + updateRealboxOutput({ + moveCursorToEnd: true, + inline: newInline, + text: assert(newFill.substr(0, newFillEnd)), + }); +} + /** * Handles a click on the restore all notification link by hiding the * notification and informing Chrome. @@ -958,6 +1410,9 @@ function onThemeChange() { renderTheme(); renderOneGoogleBarTheme(); sendThemeInfoToMostVisitedIframe(); + if ($(IDS.PROMO)) { + showPromoIfNotOverlapping(); + } } /** @@ -999,6 +1454,103 @@ function overrideExecutableTimeoutForTesting(timeout) { } /** + * @param {!Array<!AutocompleteMatch>} matches + */ +function populateAutocompleteMatches(matches) { + const realboxMatchesEl = document.createElement('div'); + realboxMatchesEl.setAttribute('role', 'listbox'); + + for (let i = 0; i < matches.length; ++i) { + const match = matches[i]; + const matchEl = document.createElement('a'); + matchEl.href = match.destinationUrl; + matchEl.setAttribute('role', 'option'); + + if (match.isSearchType) { + const icon = document.createElement('div'); + const isSearchHistory = SEARCH_HISTORY_MATCH_TYPES.includes(match.type); + icon.classList.add( + isSearchHistory ? CLASSES.CLOCK_ICON : CLASSES.SEARCH_ICON); + matchEl.appendChild(icon); + } else { + // TODO(crbug.com/997229): use chrome://favicon/<url> when perms allow. + const iconUrl = new URL('chrome-search://ntpicon/'); + iconUrl.searchParams.set('show_fallback_monogram', 'false'); + iconUrl.searchParams.set('size', '24@' + window.devicePixelRatio + 'x'); + iconUrl.searchParams.set('url', match.destinationUrl); + matchEl.style.backgroundImage = 'url(' + iconUrl.toString() + ')'; + } + + const contentsEls = + renderMatchClassifications(match.contents, match.contentsClass); + const descriptionEls = []; + const separatorEls = []; + let separatorText = ''; + + if (match.description) { + descriptionEls.push(...renderMatchClassifications( + match.description, match.descriptionClass)); + separatorText = configData.translatedStrings.realboxSeparator; + separatorEls.push(document.createTextNode(separatorText)); + } + + const ariaLabel = match.swapContentsAndDescription ? + match.description + separatorText + match.contents : + match.contents + separatorText + match.description; + matchEl.setAttribute('aria-label', ariaLabel); + + const layout = match.swapContentsAndDescription ? + [descriptionEls, separatorEls, contentsEls] : + [contentsEls, separatorEls, descriptionEls]; + + for (const col of layout) { + col.forEach(colEl => matchEl.appendChild(colEl)); + } + + if (match.supportsDeletion && configData.suggestionTransparencyEnabled) { + const icon = document.createElement('button'); + icon.title = configData.translatedStrings.removeSuggestion; + icon.classList.add(CLASSES.REMOVE_ICON); + icon.onmousedown = e => { + e.preventDefault(); // Stops default browser action (focus) + }; + icon.onclick = e => { + matchElBeingDeleted = matchEl; + window.chrome.embeddedSearch.searchBox.deleteAutocompleteMatch(i); + e.preventDefault(); // Stops default browser action (navigation) + }; + + const remove = document.createElement('div'); + remove.classList.add(CLASSES.REMOVE_MATCH); + + remove.appendChild(icon); + matchEl.appendChild(remove); + realboxMatchesEl.classList.add(CLASSES.REMOVABLE); + } + + realboxMatchesEl.append(matchEl); + } + + $(IDS.REALBOX_MATCHES).remove(); + realboxMatchesEl.id = IDS.REALBOX_MATCHES; + + $(IDS.REALBOX_INPUT_WRAPPER).appendChild(realboxMatchesEl); + + const hasMatches = matches.length > 0; + setRealboxMatchesVisible(hasMatches); + setRealboxWrapperListenForKeydown(hasMatches); + setAutocompleteMatches(matches); +} + +/** + * @param {string} input + */ +function queryAutocomplete(input) { + lastInput = input; + window.chrome.embeddedSearch.searchBox.queryAutocomplete(input); +} + +/** * @param {!Element} element * @param {!Array<string>} keys * @param {!function(Event)} handler @@ -1044,6 +1596,21 @@ function reloadTiles() { } /** + * @param {string} text + * @param {!Array<!ACMatchClassification>} classifications + * @return {!Array<!Element>} + */ +function renderMatchClassifications(text, classifications) { + return classifications.map((classification, i) => { + const classes = classificationStyleToClasses(classification.style); + const next = classifications[i + 1] || {offset: text.length}; + const classifiedText = text.substring(classification.offset, next.offset); + return classes.length ? spanWithClasses(classifiedText, classes) : + document.createTextNode(classifiedText); + }); +} + +/** * Updates the OneGoogleBar (if it is loaded) based on the current theme. * TODO(crbug.com/918582): Add support for OGB dark mode. */ @@ -1071,29 +1638,14 @@ function renderTheme() { return; } - $(IDS.NTP_CONTENTS).classList.toggle(CLASSES.DARK, info.isNtpBackgroundDark); - // Update dark mode styling. isDarkModeEnabled = window.matchMedia('(prefers-color-scheme: dark)').matches; document.body.classList.toggle('light-chip', !getUseDarkChips(info)); - const background = [ - convertToRGBAColor(info.backgroundColorRgba), info.imageUrl, - info.imageTiling, info.imageHorizontalAlignment, info.imageVerticalAlignment - ].join(' ').trim(); - - // If a custom background has been selected the image will be applied to the - // custom-background element instead of the body. - if (!info.customBackgroundConfigured) { - document.body.style.background = background; - } - // Dark mode uses a white Google logo. const useWhiteLogo = info.alternateLogo || (info.usingDefaultTheme && isDarkModeEnabled); document.body.classList.toggle(CLASSES.ALTERNATE_LOGO, useWhiteLogo); - const isNonWhiteBackground = !WHITE_BACKGROUND_COLORS.includes(background); - document.body.classList.toggle(CLASSES.NON_WHITE_BG, isNonWhiteBackground); if (info.logoColor) { document.body.style.setProperty( @@ -1103,14 +1655,22 @@ function renderTheme() { // The doodle notifier should be shown for non-default backgrounds. This // includes non-white backgrounds, excluding dark mode gray if dark mode is // enabled. - const isDefaultBackground = WHITE_BACKGROUND_COLORS.includes(background) || - (isDarkModeEnabled && background === DARK_MODE_BACKGROUND_COLOR); + const isDefaultBackground = info.usingDefaultTheme && !info.imageUrl; document.body.classList.toggle(CLASSES.USE_NOTIFIER, !isDefaultBackground); - updateThemeAttribution(info.attributionUrl, info.imageHorizontalAlignment); - setCustomThemeStyle(info); + // If a custom background has been selected the image will be applied to the + // custom-background element instead of the body. + if (!info.customBackgroundConfigured) { + document.body.style.background = [ + convertToRGBAColor(info.backgroundColorRgba), info.imageUrl, + info.imageTiling, info.imageHorizontalAlignment, + info.imageVerticalAlignment + ].join(' ').trim(); - if (info.customBackgroundConfigured) { + $(IDS.CUSTOM_BG).style.opacity = '0'; + $(IDS.CUSTOM_BG).style.backgroundImage = ''; + customize.clearAttribution(); + } else { // Do anything only if the custom background changed. const imageUrl = assert(info.imageUrl); if (!$(IDS.CUSTOM_BG).style.backgroundImage.includes(imageUrl)) { @@ -1140,12 +1700,11 @@ function renderTheme() { '' + info.attribution1, '' + info.attribution2, '' + info.attributionActionUrl); } - } else { - $(IDS.CUSTOM_BG).style.opacity = '0'; - $(IDS.CUSTOM_BG).style.backgroundImage = ''; - customize.clearAttribution(); } + updateThemeAttribution(info.attributionUrl, info.imageHorizontalAlignment); + setCustomThemeStyle(info); + $(customize.IDS.RESTORE_DEFAULT) .classList.toggle( customize.CLASSES.OPTION_DISABLED, !info.customBackgroundConfigured); @@ -1200,6 +1759,23 @@ function requestAndInsertGoogleResources() { } } +/** + * @param {!EventTarget} elToSelect + * @return {number} The selected index (if found); else -1. + */ +function selectMatchEl(elToSelect) { + let selectedIndex = -1; + Array.from($(IDS.REALBOX_MATCHES).children).forEach((matchEl, i) => { + const found = matchEl === elToSelect; + matchEl.classList.toggle(CLASSES.SELECTED, found); + matchEl.setAttribute('aria-selected', found); + if (found) { + selectedIndex = i; + } + }); + return selectedIndex; +} + /** Sends the current theme info to the most visited iframe. */ function sendThemeInfoToMostVisitedIframe() { const info = getThemeBackgroundInfo(); @@ -1236,6 +1812,11 @@ function setAttributionVisibility(show) { $(IDS.ATTRIBUTION).style.display = show ? '' : 'none'; } +/** @param {!Array<!AutocompleteMatch>} matches */ +function setAutocompleteMatches(matches) { + autocompleteMatches = matches; +} + /** * Updates the NTP style according to theme. * @param {Object} themeInfo The information about the theme. @@ -1250,9 +1831,6 @@ function setCustomThemeStyle(themeInfo) { mvxFilter = 'drop-shadow(0 0 0 ' + textColor + ')'; } - $(IDS.NTP_CONTENTS) - .classList.toggle(CLASSES.DEFAULT_THEME, themeInfo.usingDefaultTheme); - document.body.style.setProperty('--text-color', textColor); document.body.style.setProperty('--text-color-light', textColorLight); } @@ -1278,6 +1856,21 @@ function setFakeboxVisibility(show) { document.body.classList.toggle(CLASSES.HIDE_FAKEBOX, !show); } +/** @param {boolean} visible */ +function setRealboxMatchesVisible(visible) { + $(IDS.REALBOX_INPUT_WRAPPER).classList.toggle(CLASSES.SHOW_MATCHES, visible); +} + +/** @param {boolean} listen */ +function setRealboxWrapperListenForKeydown(listen) { + const realboxWrapper = $(IDS.REALBOX_INPUT_WRAPPER); + if (listen) { + realboxWrapper.addEventListener('keydown', onRealboxWrapperKeydown); + } else { + realboxWrapper.removeEventListener('keydown', onRealboxWrapperKeydown); + } +} + /** * Shows the error pop-up notification and triggers a delay to hide it. The * message will be set to |msg|. If |linkName| and |linkOnClick| are present, @@ -1315,6 +1908,63 @@ function showNotification(msg) { $(IDS.UNDO_LINK).focus(); } +function showPromoIfNotOverlapping() { + $(IDS.PROMO).style.visibility = isPromoOverlapping() ? 'hidden' : 'visible'; +} + +function showPromoIfNotOverlappingAndTrackResizes() { + showPromoIfNotOverlapping(); + // The removal before addition is to ensure only 1 event listener is ever + // active at the same time. + window.removeEventListener('resize', showPromoIfNotOverlapping); + window.addEventListener('resize', showPromoIfNotOverlapping); +} + +/** + * @param {string} text + * @param {!Array<string>} classes + * @return {!Element} + */ +function spanWithClasses(text, classes) { + const span = document.createElement('span'); + span.classList.add(...classes); + span.textContent = text; + return span; +} + +/** @param {!RealboxOutputUpdate} update */ +function updateRealboxOutput(update) { + assert(Object.keys(update).length > 0); + + const realboxEl = $(IDS.REALBOX); + const newOutput = + /** @type {!RealboxOutput} */ (Object.assign({}, lastOutput, update)); + const newAll = newOutput.text + newOutput.inline; + + const inlineDiffers = newOutput.inline !== lastOutput.inline; + const preserveSelection = !inlineDiffers && !update.moveCursorToEnd; + let needsSelectionUpdate = !preserveSelection; + + const oldSelectionStart = realboxEl.selectionStart; + + if (newAll !== realboxEl.value) { + realboxEl.value = newAll; + needsSelectionUpdate = true; // Setting .value blows away selection. + } + + if (newAll.trim() && needsSelectionUpdate) { + realboxEl.selectionStart = + preserveSelection ? oldSelectionStart : newOutput.text.length; + // If the selection shouldn't be preserved, set the selection end to the + // same as the selection start (i.e. drop selection but move cursor). + realboxEl.selectionEnd = + preserveSelection ? oldSelectionStart : newAll.length; + } + + isDeletingInput = userDeletedOutput(lastOutput, newOutput); + lastOutput = newOutput; +} + /** * Renders the attribution if the URL is present, otherwise hides it. * @param {string|undefined} url The URL of the attribution image, if any. @@ -1342,6 +1992,17 @@ function updateThemeAttribution(url, themeBackgroundAlignment) { setAttributionVisibility(true); } +/** + * @param {!RealboxOutput} before + * @param {!RealboxOutput} after + * @return {boolean} + */ +function userDeletedOutput(before, after) { + const beforeAll = before.text + before.inline; + const afterAll = after.text + after.inline; + return beforeAll.length > afterAll.length && beforeAll.startsWith(afterAll); +} + return { init: init, // Exposed for testing. listen: listen, |