// Copyright (c) 2012 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. let lastChanged = null; let lastFocused = null; const restartButton = $('experiment-restart-button'); /** @type {?function():void} */ let experimentalFeaturesResolver = null; /** @type {!Promise} */ const experimentalFeaturesReady = new Promise(resolve => { experimentalFeaturesResolver = resolve; }); /** * This variable structure is here to document the structure that the template * expects to correctly populate the page. */ /** * Takes the |experimentalFeaturesData| input argument which represents data * about all the current feature entries and populates the html jstemplate with * that data. It expects an object structure like the above. * @param {!ExperimentalFeaturesData} experimentalFeaturesData Information about * all experiments. See returnFlagsExperiments() for the structure of this * object. */ function renderTemplate(experimentalFeaturesData) { const templateToProcess = jstGetTemplate('tab-content-available-template'); const context = new JsEvalContext(experimentalFeaturesData); const content = $('tab-content-available'); // Duplicate the template into the content area. // This prevents the misrendering of available flags when the template // is rerendered. Example - resetting flags. content.textContent = ''; content.appendChild(templateToProcess); // Process the templates: available / unavailable flags. jstProcess(context, templateToProcess); // Unavailable flags are not shown on iOS. const unavailableTemplate = $('tab-content-unavailable'); if (unavailableTemplate) { jstProcess(context, $('tab-content-unavailable')); } showRestartToast(experimentalFeaturesData.needsRestart); // Add handlers to dynamically created HTML elements. let elements = document.getElementsByClassName('experiment-select'); for (const element of elements) { element.onchange = function() { const selectElement = /** @type {!HTMLSelectElement} */ (element); handleSelectExperimentalFeatureChoice( selectElement, selectElement.selectedIndex); lastChanged = element; return false; }; registerFocusEvents(element); } elements = document.getElementsByClassName('experiment-enable-disable'); for (const element of elements) { element.onchange = function() { const selectElement = /** @type {!HTMLSelectElement} */ (element); handleEnableExperimentalFeature( selectElement, selectElement.options[selectElement.selectedIndex].value == 'enabled'); lastChanged = selectElement; return false; }; registerFocusEvents(element); } elements = document.getElementsByClassName('experiment-origin-list-value'); for (const element of elements) { element.onchange = function() { handleSetOriginListFlag(element, element.value); return false; }; } assert(restartButton || cr.isIOS); if (restartButton) { restartButton.onclick = restartBrowser; } // Tab panel selection. const tabEls = document.getElementsByClassName('tab'); for (let i = 0; i < tabEls.length; ++i) { tabEls[i].addEventListener('click', function(e) { e.preventDefault(); for (let j = 0; j < tabEls.length; ++j) { tabEls[j].parentNode.classList.toggle('selected', tabEls[j] === this); tabEls[j].setAttribute('aria-selected', tabEls[j] === this); } FlagSearch.getInstance().announceSearchResults(); }); } const smallScreenCheck = window.matchMedia('(max-width: 480px)'); // Toggling of experiment description overflow content on smaller screens. if(smallScreenCheck.matches){ elements = document.querySelectorAll('.experiment .flex:first-child'); for (const element of elements) { element.onclick = () => element.classList.toggle('expand'); } } $('experiment-reset-all').onclick = resetAllFlags; highlightReferencedFlag(); const search = FlagSearch.getInstance(); search.init(); } /** * Add events to an element in order to keep track of the last focused element. * Focus restart button if a previous focus target has been set and tab key * pressed. * @param {Element} el Element to bind events to. */ function registerFocusEvents(el) { el.addEventListener('keydown', function(e) { if (lastChanged && e.key === 'Tab' && !e.shiftKey) { lastFocused = lastChanged; e.preventDefault(); restartButton.focus(); } }); el.addEventListener('blur', function() { lastChanged = null; }); } /** * Highlight an element associated with the page's location's hash. We need to * fake fragment navigation with '.scrollIntoView()', since the fragment IDs * don't actually exist until after the template code runs; normal navigation * therefore doesn't work. */ function highlightReferencedFlag() { if (window.location.hash) { const el = document.querySelector(window.location.hash); if (el && !el.classList.contains('referenced')) { // Unhighlight whatever's highlighted. if (document.querySelector('.referenced')) { document.querySelector('.referenced').classList.remove('referenced'); } // Highlight the referenced element. el.classList.add('referenced'); // Switch to unavailable tab if the flag is in this section. if ($('tab-content-unavailable').contains(el)) { $('tab-available').parentNode.classList.remove('selected'); $('tab-available').setAttribute('aria-selected', 'false'); $('tab-unavailable').parentNode.classList.add('selected'); $('tab-unavailable').setAttribute('aria-selected', 'true'); } el.scrollIntoView(); } } } /** * Gets details and configuration about the available features. The * |returnExperimentalFeatures()| will be called with reply. */ function requestExperimentalFeaturesData() { chrome.send('requestExperimentalFeatures'); } /** Restart browser and restore tabs. */ function restartBrowser() { chrome.send('restartBrowser'); } /** * Cause a text string to be announced by screen readers * @param {string} text The text that should be announced. */ function announceStatus(text) { $('screen-reader-status-message').textContent = ''; setTimeout(function() { $('screen-reader-status-message').textContent = text; }, 100); } /** Reset all flags to their default values and refresh the UI. */ function resetAllFlags() { chrome.send('resetAllFlags'); FlagSearch.getInstance().clearSearch(); announceStatus(loadTimeData.getString("reset-acknowledged")); showRestartToast(true); requestExperimentalFeaturesData(); } /** * Show the restart toast. * @param {boolean} show Setting to toggle showing / hiding the toast. */ function showRestartToast(show) { $('needs-restart').classList.toggle('show', show); const restartButton = $('experiment-restart-button'); if (restartButton) { restartButton.setAttribute("tabindex", show ? '9' : '-1'); } if (show) { $('needs-restart').setAttribute("role", "alert"); } } /** * @typedef {{ * internal_name: string, * name: string, * description: string, * enabled: boolean, * is_default: boolean, * choices: ?Array<{internal_name: string, description: string, selected: * boolean}>, supported_platforms: !Array * }} */ let Feature; /** * @typedef {{ * supportedFeatures: !Array, * unsupportedFeatures: !Array, * needsRestart: boolean, * showBetaChannelPromotion: boolean, * showDevChannelPromotion: boolean, * showOwnerWarning: boolean * }} */ let ExperimentalFeaturesData; /** * Called by the WebUI to re-populate the page with data representing the * current state of all experimental features. * @param {ExperimentalFeaturesData} experimentalFeaturesData Information about * all experimental * features in the following format: * { * supportedFeatures: [ * { * internal_name: 'Feature ID string', * name: 'Feature name', * description: 'Description', * // enabled and default are only set if the feature is single valued. * // enabled is true if the feature is currently enabled. * // is_default is true if the feature is in its default state. * enabled: true, * is_default: false, * // choices is only set if the entry has multiple values. * choices: [ * { * internal_name: 'Experimental feature ID string', * description: 'description', * selected: true * } * ], * supported_platforms: [ * 'Mac', * 'Linux' * ], * } * ], * unsupportedFeatures: [ * // Mirrors the format of |supportedFeatures| above. * ], * needsRestart: false, * showBetaChannelPromotion: false, * showDevChannelPromotion: false, * showOwnerWarning: false * } */ function returnExperimentalFeatures(experimentalFeaturesData) { const bodyContainer = $('body-container'); renderTemplate(experimentalFeaturesData); if (experimentalFeaturesData.showBetaChannelPromotion) { $('channel-promo-beta').hidden = false; } else if (experimentalFeaturesData.showDevChannelPromotion) { $('channel-promo-dev').hidden = false; } $('promos').hidden = !experimentalFeaturesData.showBetaChannelPromotion && !experimentalFeaturesData.showDevChannelPromotion; bodyContainer.style.visibility = 'visible'; const ownerWarningDiv = $('owner-warning'); if (ownerWarningDiv) { ownerWarningDiv.hidden = !experimentalFeaturesData.showOwnerWarning; } experimentalFeaturesResolver(); } /** * Handles updating the UI after experiment selections have been made. * Adds or removes experiment highlighting depending on whether the experiment * is set to the default option then shows the restart button. * @param {HTMLElement} node The select node for the experiment being changed. * @param {number} index The selected option index. */ function experimentChangesUiUpdates(node, index) { const selected = node.options[index]; /** @suppress {missingProperties} */ const experimentContainerEl = $(node.internal_name).firstElementChild; const isDefault = ('default' in selected.dataset && selected.dataset.default === '1') || (!('default' in selected.dataset) && index === 0); experimentContainerEl.classList.toggle('experiment-default', isDefault); experimentContainerEl.classList.toggle('experiment-switched', !isDefault); showRestartToast(true); } /** * Handles a 'enable' or 'disable' button getting clicked. * @param {HTMLElement} node The node for the experiment being changed. * @param {boolean} enable Whether to enable or disable the experiment. * @suppress {missingProperties} */ function handleEnableExperimentalFeature(node, enable) { /* This function is an onchange handler, which can be invoked during page * restore - see https://crbug.com/1038638. */ if (!node.internal_name) { return; } chrome.send('enableExperimentalFeature', [String(node.internal_name), String(enable)]); experimentChangesUiUpdates(node, enable ? 1 : 0); } /** @suppress {missingProperties} */ function handleSetOriginListFlag(node, value) { /* This function is an onchange handler, which can be invoked during page * restore - see https://crbug.com/1038638. */ if (!node.internal_name) { return; } chrome.send('setOriginListFlag', [String(node.internal_name), String(value)]); showRestartToast(true); } /** * Invoked when the selection of a multi-value choice is changed to the * specified index. * @param {HTMLElement} node The node for the experiment being changed. * @param {number} index The index of the option that was selected. * @suppress {missingProperties} */ function handleSelectExperimentalFeatureChoice(node, index) { /* This function is an onchange handler, which can be invoked during page * restore - see https://crbug.com/1038638. */ if (!node.internal_name) { return; } chrome.send('enableExperimentalFeature', [String(node.internal_name) + '@' + index, 'true']); experimentChangesUiUpdates(node, index); } /** @type {!FlagSearch.SearchContent} */ const emptySearchContent = Object.freeze({ link: null, title: null, description: null, }); /** * Handles in page searching. Matches against the experiment flag name. * @constructor */ const FlagSearch = function() { FlagSearch.instance_ = this; /** @private {!FlagSearch.SearchContent} */ this.experiments_ = /** @type {FlagSearch.SearchContent} */ ( Object.assign({}, emptySearchContent)); /** @private {!FlagSearch.SearchContent} */ this.unavailableExperiments_ = /** @type {FlagSearch.SearchContent} */ ( Object.assign({}, emptySearchContent)); this.searchBox_ = $('search'); this.noMatchMsg_ = document.querySelectorAll('.tab-content .no-match'); /** @private {?number} */ this.searchIntervalId_ = null; this.initialized = false; }; // Delay in ms following a keypress, before a search is made. FlagSearch.SEARCH_DEBOUNCE_TIME_MS = 150; /** * Object definition for storing the elements which are searched on. * @typedef {{ * description: ?NodeList, * link: ?NodeList, * title: ?NodeList * }} */ FlagSearch.SearchContent; /** * Get the singleton instance of FlagSearch. * @return {Object} Instance of FlagSearch. */ FlagSearch.getInstance = function() { if (FlagSearch.instance_) { return FlagSearch.instance_; } else { return new FlagSearch(); } }; FlagSearch.prototype = { /** * Initialises the in page search. Adding searchbox listeners and * collates the text elements used for string matching. */ init() { this.experiments_.link = /** @type {!NodeList} */ ( document.querySelectorAll('#tab-content-available .permalink')); this.experiments_.title = /** @type {!NodeList} */ ( document.querySelectorAll('#tab-content-available .experiment-name')); this.experiments_.description = /** @type {!NodeList} */ ( document.querySelectorAll('#tab-content-available p')); this.unavailableExperiments_.link = /** @type {!NodeList} */ ( document.querySelectorAll('#tab-content-unavailable .permalink')); this.unavailableExperiments_.title = /** @type {!NodeList} */ (document.querySelectorAll( '#tab-content-unavailable .experiment-name')); this.unavailableExperiments_.description = /** @type {!NodeList} */ ( document.querySelectorAll('#tab-content-unavailable p')); if (!this.initialized) { this.searchBox_.addEventListener('input', this.debounceSearch.bind(this)); document.querySelector('.clear-search').addEventListener('click', this.clearSearch.bind(this)); window.addEventListener('keyup', function(e) { if (document.activeElement.nodeName === 'TEXTAREA') { return; } switch (e.key) { case '/': this.searchBox_.focus(); break; case 'Escape': case 'Enter': this.searchBox_.blur(); break; } }.bind(this)); this.searchBox_.focus(); this.initialized = true; } }, /** * Clears a search showing all experiments. */ clearSearch() { this.searchBox_.value = ''; this.doSearch(); }, /** * Reset existing highlights on an element. * @param {HTMLElement} el The element to remove all highlighted mark up on. * @param {string} text Text to reset the element's textContent to. */ resetHighlights(el, text) { if (el.children) { el.textContent = text; } }, /** * Highlights the search term within a given element. * @param {string} searchTerm Search term user entered. * @param {HTMLElement} el The node containing the text to match against. * @return {boolean} Whether there was a match. */ highlightMatchInElement(searchTerm, el) { // Experiment container. const parentEl = el.parentNode.parentNode.parentNode; const text = el.textContent; const match = text.toLowerCase().indexOf(searchTerm); parentEl.classList.toggle('hidden', match === -1); if (match === -1) { this.resetHighlights(el, text); return false; } if (searchTerm !== '') { // Clear all nodes. el.textContent = ''; if (match > 0) { const textNodePrefix = document.createTextNode(text.substring(0, match)); el.appendChild(textNodePrefix); } const matchEl = document.createElement('mark'); matchEl.textContent = text.substr(match, searchTerm.length); el.appendChild(matchEl); const matchSuffix = text.substring(match + searchTerm.length); if (matchSuffix) { const textNodeSuffix = document.createTextNode(matchSuffix); el.appendChild(textNodeSuffix); } } else { this.resetHighlights(el, text); } return true; }, /** * Goes through all experiment text and highlights the relevant matches. * Only the first instance of a match in each experiment text block is * highlighted. This prevents the sea of yellow that happens using the global * find in page search. * @param {FlagSearch.SearchContent} searchContent Object containing the * experiment text elements to search against. * @param {string} searchTerm * @return {number} The number of matches found. */ highlightAllMatches(searchContent, searchTerm) { let matches = 0; for (let i = 0, j = searchContent.link.length; i < j; i++) { if (this.highlightMatchInElement(searchTerm, searchContent.title[i])) { this.resetHighlights(searchContent.description[i], searchContent.description[i].textContent); this.resetHighlights(searchContent.link[i], searchContent.link[i].textContent); matches++; continue; } if (this.highlightMatchInElement(searchTerm, searchContent.description[i])) { this.resetHighlights(searchContent.title[i], searchContent.title[i].textContent); this.resetHighlights(searchContent.link[i], searchContent.link[i].textContent); matches++; continue; } // Match links, replace spaces with hyphens as flag names don't // have spaces. if (this.highlightMatchInElement(searchTerm.replace(/\s/, '-'), searchContent.link[i])) { this.resetHighlights(searchContent.title[i], searchContent.title[i].textContent); this.resetHighlights(searchContent.description[i], searchContent.description[i].textContent); matches++; } } return matches; }, /** * Performs a search against the experiment title, description, permalink. */ doSearch() { const searchTerm = this.searchBox_.value.trim().toLowerCase(); if (searchTerm || searchTerm === '') { document.body.classList.toggle('searching', searchTerm); // Available experiments this.noMatchMsg_[0].classList.toggle( 'hidden', this.highlightAllMatches(this.experiments_, searchTerm) > 0); // Unavailable experiments this.noMatchMsg_[1].classList.toggle( 'hidden', this.highlightAllMatches(this.unavailableExperiments_, searchTerm) > 0); this.announceSearchResults(); } this.searchIntervalId_ = null; }, announceSearchResults() { const searchTerm = this.searchBox_.value.trim().toLowerCase(); if (!searchTerm) { return; } let tabAvailable = true; const tabEls = document.getElementsByClassName('tab'); for (let i = 0; i < tabEls.length; ++i) { if (tabEls[i].parentNode.classList.contains('selected')) { tabAvailable = tabEls[i].id === 'tab-available'; } } const seletedTabId = tabAvailable ? '#tab-content-available' : '#tab-content-unavailable'; const queryString = seletedTabId + ' .experiment:not(.hidden)'; const total = document.querySelectorAll(queryString).length; if (total) { announceStatus( total === 1 ? loadTimeData.getStringF('searchResultsSingular', searchTerm) : loadTimeData.getStringF( 'searchResultsPlural', total, searchTerm)); } }, /** * Debounces the search to improve performance and prevent too many searches * from being initiated. */ debounceSearch() { if (this.searchIntervalId_) { clearTimeout(this.searchIntervalId_); } this.searchIntervalId_ = setTimeout(this.doSearch.bind(this), FlagSearch.SEARCH_DEBOUNCE_TIME_MS); } }; /** * Allows the restart button to jump back to the previously focused experiment * in the list instead of going to the top of the page. */ function setupRestartButton() { restartButton.addEventListener('keydown', function(e) { if (e.shiftKey && e.key === 'Tab' && lastFocused) { e.preventDefault(); lastFocused.focus(); } }); restartButton.addEventListener('blur', () => { lastFocused = null; }); } document.addEventListener('DOMContentLoaded', function() { // Get and display the data upon loading. requestExperimentalFeaturesData(); setupRestartButton(); cr.ui.FocusOutlineManager.forDocument(document); }); // Update the highlighted flag when the hash changes. window.addEventListener('hashchange', highlightReferencedFlag);