diff options
Diffstat (limited to 'application/basilisk/base/content/contentSearchUI.js')
-rw-r--r-- | application/basilisk/base/content/contentSearchUI.js | 896 |
1 files changed, 896 insertions, 0 deletions
diff --git a/application/basilisk/base/content/contentSearchUI.js b/application/basilisk/base/content/contentSearchUI.js new file mode 100644 index 000000000..509532a88 --- /dev/null +++ b/application/basilisk/base/content/contentSearchUI.js @@ -0,0 +1,896 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.ContentSearchUIController = (function() { + +const MAX_DISPLAYED_SUGGESTIONS = 6; +const SUGGESTION_ID_PREFIX = "searchSuggestion"; +const ONE_OFF_ID_PREFIX = "oneOff"; + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * Creates a new object that manages search suggestions and their UI for a text + * box. + * + * The UI consists of an html:table that's inserted into the DOM after the given + * text box and styled so that it appears as a dropdown below the text box. + * + * @param inputElement + * Search suggestions will be based on the text in this text box. + * Assumed to be an html:input. xul:textbox is untested but might work. + * @param tableParent + * The suggestion table is appended as a child to this element. Since + * the table is absolutely positioned and its top and left values are set + * to be relative to the top and left of the page, either the parent and + * all its ancestors should not be positioned elements (i.e., their + * positions should be "static"), or the parent's position should be the + * top left of the page. + * @param healthReportKey + * This will be sent with the search data for FHR to record the search. + * @param searchPurpose + * Sent with search data, see nsISearchEngine.getSubmission. + * @param idPrefix + * The IDs of elements created by the object will be prefixed with this + * string. + */ +function ContentSearchUIController(inputElement, tableParent, healthReportKey, + searchPurpose, idPrefix = "") { + this.input = inputElement; + this._idPrefix = idPrefix; + this._healthReportKey = healthReportKey; + this._searchPurpose = searchPurpose; + + let tableID = idPrefix + "searchSuggestionTable"; + this.input.autocomplete = "off"; + this.input.setAttribute("aria-autocomplete", "true"); + this.input.setAttribute("aria-controls", tableID); + tableParent.appendChild(this._makeTable(tableID)); + + this.input.addEventListener("keypress", this); + this.input.addEventListener("input", this); + this.input.addEventListener("focus", this); + this.input.addEventListener("blur", this); + window.addEventListener("ContentSearchService", this); + + this._stickyInputValue = ""; + this._hideSuggestions(); + + this._getSearchEngines(); + this._getStrings(); +} + +ContentSearchUIController.prototype = { + + _oneOffButtons: [], + // Setting up the one off buttons causes an uninterruptible reflow. If we + // receive the list of engines while the newtab page is loading, this reflow + // may regress performance - so we set this flag and only set up the buttons + // if it's set when the suggestions table is actually opened. + _pendingOneOffRefresh: undefined, + + get defaultEngine() { + return this._defaultEngine; + }, + + set defaultEngine(engine) { + if (this._defaultEngine && this._defaultEngine.icon) { + URL.revokeObjectURL(this._defaultEngine.icon); + } + let icon; + if (engine.iconBuffer) { + icon = this._getFaviconURIFromBuffer(engine.iconBuffer); + } else { + icon = this._getImageURIForCurrentResolution( + "chrome://mozapps/skin/places/defaultFavicon.png"); + } + this._defaultEngine = { + name: engine.name, + icon, + }; + this._updateDefaultEngineHeader(); + + if (engine && document.activeElement == this.input) { + this._speculativeConnect(); + } + }, + + get engines() { + return this._engines; + }, + + set engines(val) { + this._engines = val; + this._pendingOneOffRefresh = true; + }, + + // The selectedIndex is the index of the element with the "selected" class in + // the list obtained by concatenating the suggestion rows, one-off buttons, and + // search settings button. + get selectedIndex() { + let allElts = [...this._suggestionsList.children, + ...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton")]; + for (let i = 0; i < allElts.length; ++i) { + let elt = allElts[i]; + if (elt.classList.contains("selected")) { + return i; + } + } + return -1; + }, + + set selectedIndex(idx) { + // Update the table's rows, and the input when there is a selection. + this._table.removeAttribute("aria-activedescendant"); + this.input.removeAttribute("aria-activedescendant"); + + let allElts = [...this._suggestionsList.children, + ...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton")]; + // If we are selecting a suggestion and a one-off is selected, don't deselect it. + let excludeIndex = idx < this.numSuggestions && this.selectedButtonIndex > -1 ? + this.numSuggestions + this.selectedButtonIndex : -1; + for (let i = 0; i < allElts.length; ++i) { + let elt = allElts[i]; + let ariaSelectedElt = i < this.numSuggestions ? elt.firstChild : elt; + if (i == idx) { + elt.classList.add("selected"); + ariaSelectedElt.setAttribute("aria-selected", "true"); + this.input.setAttribute("aria-activedescendant", ariaSelectedElt.id); + } else if (i != excludeIndex) { + elt.classList.remove("selected"); + ariaSelectedElt.setAttribute("aria-selected", "false"); + } + } + }, + + get selectedButtonIndex() { + let elts = [...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton")]; + for (let i = 0; i < elts.length; ++i) { + if (elts[i].classList.contains("selected")) { + return i; + } + } + return -1; + }, + + set selectedButtonIndex(idx) { + let elts = [...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton")]; + for (let i = 0; i < elts.length; ++i) { + let elt = elts[i]; + if (i == idx) { + elt.classList.add("selected"); + elt.setAttribute("aria-selected", "true"); + } else { + elt.classList.remove("selected"); + elt.setAttribute("aria-selected", "false"); + } + } + }, + + get selectedEngineName() { + let selectedElt = this._oneOffsTable.querySelector(".selected"); + if (selectedElt) { + return selectedElt.engineName; + } + return this.defaultEngine.name; + }, + + get numSuggestions() { + return this._suggestionsList.children.length; + }, + + selectAndUpdateInput(idx) { + this.selectedIndex = idx; + let newValue = this.suggestionAtIndex(idx) || this._stickyInputValue; + // Setting the input value when the value has not changed commits the current + // IME composition, which we don't want to do. + if (this.input.value != newValue) { + this.input.value = newValue; + } + this._updateSearchWithHeader(); + }, + + suggestionAtIndex(idx) { + let row = this._suggestionsList.children[idx]; + return row ? row.textContent : null; + }, + + deleteSuggestionAtIndex(idx) { + // Only form history suggestions can be deleted. + if (this.isFormHistorySuggestionAtIndex(idx)) { + let suggestionStr = this.suggestionAtIndex(idx); + this._sendMsg("RemoveFormHistoryEntry", suggestionStr); + this._suggestionsList.children[idx].remove(); + this.selectAndUpdateInput(-1); + } + }, + + isFormHistorySuggestionAtIndex(idx) { + let row = this._suggestionsList.children[idx]; + return row && row.classList.contains("formHistory"); + }, + + addInputValueToFormHistory() { + this._sendMsg("AddFormHistoryEntry", this.input.value); + }, + + handleEvent(event) { + this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event); + }, + + _onCommand(aEvent) { + if (this.selectedButtonIndex == this._oneOffButtons.length) { + // Settings button was selected. + this._sendMsg("ManageEngines"); + return; + } + + this.search(aEvent); + + if (aEvent) { + aEvent.preventDefault(); + } + }, + + search(aEvent) { + if (!this.defaultEngine) { + return; // Not initialized yet. + } + + let searchText = this.input; + let searchTerms; + if (this._table.hidden || + aEvent.originalTarget.id == "contentSearchDefaultEngineHeader" || + aEvent instanceof KeyboardEvent) { + searchTerms = searchText.value; + } else { + searchTerms = this.suggestionAtIndex(this.selectedIndex) || searchText.value; + } + // Send an event that will perform a search and Firefox Health Report will + // record that a search from the healthReportKey passed to the constructor. + let eventData = { + engineName: this.selectedEngineName, + searchString: searchTerms, + healthReportKey: this._healthReportKey, + searchPurpose: this._searchPurpose, + originalEvent: { + shiftKey: aEvent.shiftKey, + ctrlKey: aEvent.ctrlKey, + metaKey: aEvent.metaKey, + altKey: aEvent.altKey, + }, + }; + if ("button" in aEvent) { + eventData.originalEvent.button = aEvent.button; + } + + if (this.suggestionAtIndex(this.selectedIndex)) { + eventData.selection = { + index: this.selectedIndex, + kind: undefined, + }; + if (aEvent instanceof MouseEvent) { + eventData.selection.kind = "mouse"; + } else if (aEvent instanceof KeyboardEvent) { + eventData.selection.kind = "key"; + } + } + + this._sendMsg("Search", eventData); + this.addInputValueToFormHistory(); + }, + + _onInput() { + if (!this.input.value) { + this._stickyInputValue = ""; + this._hideSuggestions(); + } else if (this.input.value != this._stickyInputValue) { + // Only fetch new suggestions if the input value has changed. + this._getSuggestions(); + this.selectAndUpdateInput(-1); + } + this._updateSearchWithHeader(); + }, + + _onKeypress(event) { + let selectedIndexDelta = 0; + let selectedSuggestionDelta = 0; + let selectedOneOffDelta = 0; + + switch (event.keyCode) { + case event.DOM_VK_UP: + if (this._table.hidden) { + return; + } + if (event.getModifierState("Accel")) { + if (event.shiftKey) { + selectedSuggestionDelta = -1; + break; + } + this._cycleCurrentEngine(true); + break; + } + if (event.altKey) { + selectedOneOffDelta = -1; + break; + } + selectedIndexDelta = -1; + break; + case event.DOM_VK_DOWN: + if (this._table.hidden) { + this._getSuggestions(); + return; + } + if (event.getModifierState("Accel")) { + if (event.shiftKey) { + selectedSuggestionDelta = 1; + break; + } + this._cycleCurrentEngine(false); + break; + } + if (event.altKey) { + selectedOneOffDelta = 1; + break; + } + selectedIndexDelta = 1; + break; + case event.DOM_VK_TAB: + if (this._table.hidden) { + return; + } + // Shift+tab when either the first or no one-off is selected, as well as + // tab when the settings button is selected, should change focus as normal. + if ((this.selectedButtonIndex <= 0 && event.shiftKey) || + this.selectedButtonIndex == this._oneOffButtons.length && !event.shiftKey) { + return; + } + selectedOneOffDelta = event.shiftKey ? -1 : 1; + break; + case event.DOM_VK_RIGHT: + // Allow normal caret movement until the caret is at the end of the input. + if (this.input.selectionStart != this.input.selectionEnd || + this.input.selectionEnd != this.input.value.length) { + return; + } + if (this.numSuggestions && this.selectedIndex >= 0 && + this.selectedIndex < this.numSuggestions) { + this.input.value = this.suggestionAtIndex(this.selectedIndex); + this.input.setAttribute("selection-index", this.selectedIndex); + this.input.setAttribute("selection-kind", "key"); + } else { + // If we didn't select anything, make sure to remove the attributes + // in case they were populated last time. + this.input.removeAttribute("selection-index"); + this.input.removeAttribute("selection-kind"); + } + this._stickyInputValue = this.input.value; + this._hideSuggestions(); + return; + case event.DOM_VK_RETURN: + this._onCommand(event); + return; + case event.DOM_VK_DELETE: + if (this.selectedIndex >= 0) { + this.deleteSuggestionAtIndex(this.selectedIndex); + } + return; + case event.DOM_VK_ESCAPE: + if (!this._table.hidden) { + this._hideSuggestions(); + } + return; + default: + return; + } + + let currentIndex = this.selectedIndex; + if (selectedIndexDelta) { + let newSelectedIndex = currentIndex + selectedIndexDelta; + if (newSelectedIndex < -1) { + newSelectedIndex = this.numSuggestions + this._oneOffButtons.length; + } + // If are moving up from the first one off, we have to deselect the one off + // manually because the selectedIndex setter tries to exclude the selected + // one-off (which is desirable for accel+shift+up/down). + if (currentIndex == this.numSuggestions && selectedIndexDelta == -1) { + this.selectedButtonIndex = -1; + } + this.selectAndUpdateInput(newSelectedIndex); + } else if (selectedSuggestionDelta) { + let newSelectedIndex; + if (currentIndex >= this.numSuggestions || currentIndex == -1) { + // No suggestion already selected, select the first/last one appropriately. + newSelectedIndex = selectedSuggestionDelta == 1 ? + 0 : this.numSuggestions - 1; + } else { + newSelectedIndex = currentIndex + selectedSuggestionDelta; + } + if (newSelectedIndex >= this.numSuggestions) { + newSelectedIndex = -1; + } + this.selectAndUpdateInput(newSelectedIndex); + } else if (selectedOneOffDelta) { + let newSelectedIndex; + let currentButton = this.selectedButtonIndex; + if (currentButton == -1 || currentButton == this._oneOffButtons.length) { + // No one-off already selected, select the first/last one appropriately. + newSelectedIndex = selectedOneOffDelta == 1 ? + 0 : this._oneOffButtons.length - 1; + } else { + newSelectedIndex = currentButton + selectedOneOffDelta; + } + // Allow selection of the settings button via the tab key. + if (newSelectedIndex == this._oneOffButtons.length && + event.keyCode != event.DOM_VK_TAB) { + newSelectedIndex = -1; + } + this.selectedButtonIndex = newSelectedIndex; + } + + // Prevent the input's caret from moving. + event.preventDefault(); + }, + + _currentEngineIndex: -1, + _cycleCurrentEngine(aReverse) { + if ((this._currentEngineIndex == this._engines.length - 1 && !aReverse) || + (this._currentEngineIndex == 0 && aReverse)) { + return; + } + this._currentEngineIndex += aReverse ? -1 : 1; + let engineName = this._engines[this._currentEngineIndex].name; + this._sendMsg("SetCurrentEngine", engineName); + }, + + _onFocus() { + if (this._mousedown) { + return; + } + // When the input box loses focus to something in our table, we refocus it + // immediately. This causes the focus highlight to flicker, so we set a + // custom attribute which consumers should use for focus highlighting. This + // attribute is removed only when we do not immediately refocus the input + // box, thus eliminating flicker. + this.input.setAttribute("keepfocus", "true"); + this._speculativeConnect(); + }, + + _onBlur() { + if (this._mousedown) { + // At this point, this.input has lost focus, but a new element has not yet + // received it. If we re-focus this.input directly, the new element will + // steal focus immediately, so we queue it instead. + setTimeout(() => this.input.focus(), 0); + return; + } + this.input.removeAttribute("keepfocus"); + this._hideSuggestions(); + }, + + _onMousemove(event) { + let idx = this._indexOfTableItem(event.target); + if (idx >= this.numSuggestions) { + this.selectedButtonIndex = idx - this.numSuggestions; + return; + } + this.selectedIndex = idx; + }, + + _onMouseup(event) { + if (event.button == 2) { + return; + } + this._onCommand(event); + }, + + _onMouseout(event) { + // We only deselect one-off buttons and the settings button when they are + // moused out. + let idx = this._indexOfTableItem(event.originalTarget); + if (idx >= this.numSuggestions) { + this.selectedButtonIndex = -1; + } + }, + + _onClick(event) { + this._onMouseup(event); + }, + + _onContentSearchService(event) { + let methodName = "_onMsg" + event.detail.type; + if (methodName in this) { + this[methodName](event.detail.data); + } + }, + + _onMsgFocusInput(event) { + this.input.focus(); + }, + + _onMsgSuggestions(suggestions) { + // Ignore the suggestions if their search string or engine doesn't match + // ours. Due to the async nature of message passing, this can easily happen + // when the user types quickly. + if (this._stickyInputValue != suggestions.searchString || + this.defaultEngine.name != suggestions.engineName) { + return; + } + + this._clearSuggestionRows(); + + // Position and size the table. + let { left } = this.input.getBoundingClientRect(); + this._table.style.top = this.input.offsetHeight + "px"; + this._table.style.minWidth = this.input.offsetWidth + "px"; + this._table.style.maxWidth = (window.innerWidth - left - 40) + "px"; + + // Add the suggestions to the table. + let searchWords = + new Set(suggestions.searchString.trim().toLowerCase().split(/\s+/)); + for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) { + let type, idx; + if (i < suggestions.formHistory.length) { + [type, idx] = ["formHistory", i]; + } else { + let j = i - suggestions.formHistory.length; + if (j < suggestions.remote.length) { + [type, idx] = ["remote", j]; + } else { + break; + } + } + this._suggestionsList.appendChild( + this._makeTableRow(type, suggestions[type][idx], i, searchWords)); + } + + if (this._table.hidden) { + this.selectedIndex = -1; + if (this._pendingOneOffRefresh) { + this._setUpOneOffButtons(); + delete this._pendingOneOffRefresh; + } + this._currentEngineIndex = + this._engines.findIndex(aEngine => aEngine.name == this.defaultEngine.name); + this._table.hidden = false; + this.input.setAttribute("aria-expanded", "true"); + this._originalDefaultEngine = { + name: this.defaultEngine.name, + icon: this.defaultEngine.icon, + }; + } + }, + + _onMsgSuggestionsCancelled() { + if (!this._table.hidden) { + this._hideSuggestions(); + } + }, + + _onMsgState(state) { + this.engines = state.engines; + // No point updating the default engine (and the header) if there's no change. + if (this.defaultEngine && + this.defaultEngine.name == state.currentEngine.name && + this.defaultEngine.icon == state.currentEngine.icon) { + return; + } + this.defaultEngine = state.currentEngine; + }, + + _onMsgCurrentState(state) { + this._onMsgState(state); + }, + + _onMsgCurrentEngine(engine) { + this.defaultEngine = engine; + this._pendingOneOffRefresh = true; + }, + + _onMsgStrings(strings) { + this._strings = strings; + this._updateDefaultEngineHeader(); + this._updateSearchWithHeader(); + document.getElementById("contentSearchSettingsButton").textContent = + this._strings.searchSettings; + this.input.setAttribute("placeholder", this._strings.searchPlaceholder); + }, + + _updateDefaultEngineHeader() { + let header = document.getElementById("contentSearchDefaultEngineHeader"); + header.firstChild.setAttribute("src", this.defaultEngine.icon); + if (!this._strings) { + return; + } + while (header.firstChild.nextSibling) { + header.firstChild.nextSibling.remove(); + } + header.appendChild(document.createTextNode( + this._strings.searchHeader.replace("%S", this.defaultEngine.name))); + }, + + _updateSearchWithHeader() { + if (!this._strings) { + return; + } + let searchWithHeader = document.getElementById("contentSearchSearchWithHeader"); + if (this.input.value) { + searchWithHeader.innerHTML = this._strings.searchForSomethingWith; + searchWithHeader.querySelector(".contentSearchSearchWithHeaderSearchText").textContent = this.input.value; + } else { + searchWithHeader.textContent = this._strings.searchWithHeader; + } + }, + + _speculativeConnect() { + if (this.defaultEngine) { + this._sendMsg("SpeculativeConnect", this.defaultEngine.name); + } + }, + + _makeTableRow(type, suggestionStr, currentRow, searchWords) { + let row = document.createElementNS(HTML_NS, "tr"); + row.dir = "auto"; + row.classList.add("contentSearchSuggestionRow"); + row.classList.add(type); + row.setAttribute("role", "presentation"); + row.addEventListener("mousemove", this); + row.addEventListener("mouseup", this); + + let entry = document.createElementNS(HTML_NS, "td"); + let img = document.createElementNS(HTML_NS, "div"); + img.setAttribute("class", "historyIcon"); + entry.appendChild(img); + entry.classList.add("contentSearchSuggestionEntry"); + entry.setAttribute("role", "option"); + entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow; + entry.setAttribute("aria-selected", "false"); + + let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/); + for (let i = 0; i < suggestionWords.length; i++) { + let word = suggestionWords[i]; + let wordSpan = document.createElementNS(HTML_NS, "span"); + if (searchWords.has(word)) { + wordSpan.classList.add("typed"); + } + wordSpan.textContent = word; + entry.appendChild(wordSpan); + if (i < suggestionWords.length - 1) { + entry.appendChild(document.createTextNode(" ")); + } + } + + row.appendChild(entry); + return row; + }, + + // Converts favicon array buffer into a data URI. + _getFaviconURIFromBuffer(buffer) { + let blob = new Blob([buffer]); + return URL.createObjectURL(blob); + }, + + // Adds "@2x" to the name of the given PNG url for "retina" screens. + _getImageURIForCurrentResolution(uri) { + if (window.devicePixelRatio > 1) { + return uri.replace(/\.png$/, "@2x.png"); + } + return uri; + }, + + _getSearchEngines() { + this._sendMsg("GetState"); + }, + + _getStrings() { + this._sendMsg("GetStrings"); + }, + + _getSuggestions() { + this._stickyInputValue = this.input.value; + if (this.defaultEngine) { + this._sendMsg("GetSuggestions", { + engineName: this.defaultEngine.name, + searchString: this.input.value, + }); + } + }, + + _clearSuggestionRows() { + while (this._suggestionsList.firstElementChild) { + this._suggestionsList.firstElementChild.remove(); + } + }, + + _hideSuggestions() { + this.input.setAttribute("aria-expanded", "false"); + this.selectedIndex = -1; + this.selectedButtonIndex = -1; + this._currentEngineIndex = -1; + this._table.hidden = true; + }, + + _indexOfTableItem(elt) { + if (elt.classList.contains("contentSearchOneOffItem")) { + return this.numSuggestions + this._oneOffButtons.indexOf(elt); + } + if (elt.classList.contains("contentSearchSettingsButton")) { + return this.numSuggestions + this._oneOffButtons.length; + } + while (elt && elt.localName != "tr") { + elt = elt.parentNode; + } + if (!elt) { + throw new Error("Element is not a row"); + } + return elt.rowIndex; + }, + + _makeTable(id) { + this._table = document.createElementNS(HTML_NS, "table"); + this._table.id = id; + this._table.hidden = true; + this._table.classList.add("contentSearchSuggestionTable"); + this._table.setAttribute("role", "presentation"); + + // When the search input box loses focus, we want to immediately give focus + // back to it if the blur was because the user clicked somewhere in the table. + // onBlur uses the _mousedown flag to detect this. + this._table.addEventListener("mousedown", () => { this._mousedown = true; }); + document.addEventListener("mouseup", () => { delete this._mousedown; }); + + // Deselect the selected element on mouseout if it wasn't a suggestion. + this._table.addEventListener("mouseout", this); + + // If a search is loaded in the same tab, ensure the suggestions dropdown + // is hidden immediately when the page starts loading and not when it first + // appears, in order to provide timely feedback to the user. + window.addEventListener("beforeunload", () => { this._hideSuggestions(); }); + + let headerRow = document.createElementNS(HTML_NS, "tr"); + let header = document.createElementNS(HTML_NS, "td"); + headerRow.setAttribute("class", "contentSearchHeaderRow"); + header.setAttribute("class", "contentSearchHeader"); + let iconImg = document.createElementNS(HTML_NS, "img"); + header.appendChild(iconImg); + header.id = "contentSearchDefaultEngineHeader"; + headerRow.appendChild(header); + headerRow.addEventListener("click", this); + this._table.appendChild(headerRow); + + let row = document.createElementNS(HTML_NS, "tr"); + row.setAttribute("class", "contentSearchSuggestionsContainer"); + let cell = document.createElementNS(HTML_NS, "td"); + cell.setAttribute("class", "contentSearchSuggestionsContainer"); + this._suggestionsList = document.createElementNS(HTML_NS, "table"); + this._suggestionsList.setAttribute("class", "contentSearchSuggestionsList"); + cell.appendChild(this._suggestionsList); + row.appendChild(cell); + this._table.appendChild(row); + this._suggestionsList.setAttribute("role", "listbox"); + + this._oneOffsTable = document.createElementNS(HTML_NS, "table"); + this._oneOffsTable.setAttribute("class", "contentSearchOneOffsTable"); + this._oneOffsTable.classList.add("contentSearchSuggestionsContainer"); + this._oneOffsTable.setAttribute("role", "group"); + this._table.appendChild(this._oneOffsTable); + + headerRow = document.createElementNS(HTML_NS, "tr"); + header = document.createElementNS(HTML_NS, "td"); + headerRow.setAttribute("class", "contentSearchHeaderRow"); + header.setAttribute("class", "contentSearchHeader"); + headerRow.appendChild(header); + header.id = "contentSearchSearchWithHeader"; + this._oneOffsTable.appendChild(headerRow); + + let button = document.createElementNS(HTML_NS, "button"); + button.setAttribute("class", "contentSearchSettingsButton"); + button.classList.add("contentSearchHeaderRow"); + button.classList.add("contentSearchHeader"); + button.id = "contentSearchSettingsButton"; + button.addEventListener("click", this); + button.addEventListener("mousemove", this); + this._table.appendChild(button); + + return this._table; + }, + + _setUpOneOffButtons() { + // Sometimes we receive a CurrentEngine message from the ContentSearch service + // before we've received a State message - i.e. before we have our engines. + if (!this._engines) { + return; + } + + while (this._oneOffsTable.firstChild.nextSibling) { + this._oneOffsTable.firstChild.nextSibling.remove(); + } + + this._oneOffButtons = []; + + let engines = this._engines.filter(aEngine => aEngine.name != this.defaultEngine.name) + .filter(aEngine => !aEngine.hidden); + if (!engines.length) { + this._oneOffsTable.hidden = true; + return; + } + + const kDefaultButtonWidth = 49; // 48px + 1px border. + let rowWidth = this.input.offsetWidth - 2; // 2px border. + let enginesPerRow = Math.floor(rowWidth / kDefaultButtonWidth); + let buttonWidth = Math.floor(rowWidth / enginesPerRow); + + let row = document.createElementNS(HTML_NS, "tr"); + let cell = document.createElementNS(HTML_NS, "td"); + row.setAttribute("class", "contentSearchSuggestionsContainer"); + cell.setAttribute("class", "contentSearchSuggestionsContainer"); + + for (let i = 0; i < engines.length; ++i) { + let engine = engines[i]; + if (i > 0 && i % enginesPerRow == 0) { + row.appendChild(cell); + this._oneOffsTable.appendChild(row); + row = document.createElementNS(HTML_NS, "tr"); + cell = document.createElementNS(HTML_NS, "td"); + row.setAttribute("class", "contentSearchSuggestionsContainer"); + cell.setAttribute("class", "contentSearchSuggestionsContainer"); + } + let button = document.createElementNS(HTML_NS, "button"); + button.setAttribute("class", "contentSearchOneOffItem"); + let img = document.createElementNS(HTML_NS, "img"); + let uri; + if (engine.iconBuffer) { + uri = this._getFaviconURIFromBuffer(engine.iconBuffer); + } else { + uri = this._getImageURIForCurrentResolution( + "chrome://browser/skin/search-engine-placeholder.png"); + } + img.setAttribute("src", uri); + img.addEventListener("load", function imgLoad() { + img.removeEventListener("load", imgLoad); + URL.revokeObjectURL(uri); + }); + button.appendChild(img); + button.style.width = buttonWidth + "px"; + button.setAttribute("title", engine.name); + + button.engineName = engine.name; + button.addEventListener("click", this); + button.addEventListener("mousemove", this); + + if (engines.length - i <= enginesPerRow - (i % enginesPerRow)) { + button.classList.add("last-row"); + } + + if ((i + 1) % enginesPerRow == 0) { + button.classList.add("end-of-row"); + } + + button.id = ONE_OFF_ID_PREFIX + i; + cell.appendChild(button); + this._oneOffButtons.push(button); + } + row.appendChild(cell); + this._oneOffsTable.appendChild(row); + this._oneOffsTable.hidden = false; + }, + + _sendMsg(type, data = null) { + dispatchEvent(new CustomEvent("ContentSearchClient", { + detail: { + type, + data, + }, + })); + }, +}; + +return ContentSearchUIController; +})(); |