diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/UrlbarValueFormatter.sys.mjs | 499 |
1 files changed, 499 insertions, 0 deletions
diff --git a/browser/components/urlbar/UrlbarValueFormatter.sys.mjs b/browser/components/urlbar/UrlbarValueFormatter.sys.mjs new file mode 100644 index 0000000000..87bb198ec4 --- /dev/null +++ b/browser/components/urlbar/UrlbarValueFormatter.sys.mjs @@ -0,0 +1,499 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +/** + * Applies URL highlighting and other styling to the text in the urlbar input, + * depending on the text. + */ +export class UrlbarValueFormatter { + /** + * @param {UrlbarInput} urlbarInput + * The parent instance of UrlbarInput + */ + constructor(urlbarInput) { + this.urlbarInput = urlbarInput; + this.window = this.urlbarInput.window; + this.document = this.window.document; + + // This is used only as an optimization to avoid removing formatting in + // the _remove* format methods when no formatting is actually applied. + this._formattingApplied = false; + + this.window.addEventListener("resize", this); + } + + get inputField() { + return this.urlbarInput.inputField; + } + + get scheme() { + return this.urlbarInput.querySelector("#urlbar-scheme"); + } + + async update() { + let instance = (this._updateInstance = {}); + + // _getUrlMetaData does URI fixup, which depends on the search service, so + // make sure it's initialized. It can be uninitialized here on session + // restore. Skip this if the service is already initialized in order to + // avoid the async call in the common case. However, we can't access + // Service.search before first paint (delayed startup) because there's a + // performance test that prohibits it, so first await delayed startup. + if (!this.window.gBrowserInit.delayedStartupFinished) { + await this.window.delayedStartupPromise; + if (this._updateInstance != instance) { + return; + } + } + if (!Services.search.isInitialized) { + await Services.search.init(); + if (this._updateInstance != instance) { + return; + } + } + + // If this window is being torn down, stop here + if (!this.window.docShell) { + return; + } + + // Cleanup that must be done in any case, even if there's no value. + this.urlbarInput.removeAttribute("domaindir"); + this.scheme.value = ""; + + if (!this.inputField.value) { + return; + } + + // Remove the current formatting. + this._removeURLFormat(); + this._removeSearchAliasFormat(); + + // Apply new formatting. Formatter methods should return true if they + // successfully formatted the value and false if not. We apply only + // one formatter at a time, so we stop at the first successful one. + this._formattingApplied = this._formatURL() || this._formatSearchAlias(); + } + + _ensureFormattedHostVisible(urlMetaData) { + // Used to avoid re-entrance in the requestAnimationFrame callback. + let instance = (this._formatURLInstance = {}); + + // Make sure the host is always visible. Since it is aligned on + // the first strong directional character, we set scrollLeft + // appropriately to ensure the domain stays visible in case of an + // overflow. + this.window.requestAnimationFrame(() => { + // Check for re-entrance. On focus change this formatting code is + // invoked regardless, thus this should be enough. + if (this._formatURLInstance != instance) { + return; + } + + // In the future, for example in bug 525831, we may add a forceRTL + // char just after the domain, and in such a case we should not + // scroll to the left. + urlMetaData = urlMetaData || this._getUrlMetaData(); + if (!urlMetaData) { + this.urlbarInput.removeAttribute("domaindir"); + return; + } + let { url, preDomain, domain } = urlMetaData; + let directionality = this.window.windowUtils.getDirectionFromText(domain); + if ( + directionality == this.window.windowUtils.DIRECTION_RTL && + url[preDomain.length + domain.length] != "\u200E" + ) { + this.urlbarInput.setAttribute("domaindir", "rtl"); + this.inputField.scrollLeft = this.inputField.scrollLeftMax; + } else { + this.urlbarInput.setAttribute("domaindir", "ltr"); + this.inputField.scrollLeft = 0; + } + this.urlbarInput.updateTextOverflow(); + }); + } + + _getUrlMetaData() { + if (this.urlbarInput.focused) { + return null; + } + + let url = this.inputField.value; + // getFixupURIInfo logs an error if the URL is empty. Avoid that by + // returning early. + if (!url) { + return null; + } + let browser = this.window.gBrowser.selectedBrowser; + + // Since doing a full URIFixup and offset calculations is expensive, we + // keep the metadata cached in the browser itself, so when switching tabs + // we can skip most of this. + if (browser._urlMetaData && browser._urlMetaData.url == url) { + return browser._urlMetaData.data; + } + browser._urlMetaData = { url, data: null }; + + // Get the URL from the fixup service: + let flags = + Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Services.uriFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) { + flags |= Services.uriFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + let uriInfo; + try { + uriInfo = Services.uriFixup.getFixupURIInfo(url, flags); + } catch (ex) {} + // Ignore if we couldn't make a URI out of this, the URI resulted in a search, + // or the URI has a non-http(s)/ftp protocol. + if ( + !uriInfo || + !uriInfo.fixedURI || + uriInfo.keywordProviderName || + !["http", "https", "ftp"].includes(uriInfo.fixedURI.scheme) + ) { + return null; + } + + // If we trimmed off the http scheme, ensure we stick it back on before + // trying to figure out what domain we're accessing, so we don't get + // confused by user:pass@host http URLs. We later use + // trimmedLength to ensure we don't count the length of a trimmed protocol + // when determining which parts of the URL to highlight as "preDomain". + let trimmedLength = 0; + if (uriInfo.fixedURI.scheme == "http" && !url.startsWith("http://")) { + url = "http://" + url; + trimmedLength = "http://".length; + } + + // This RegExp is not a perfect match, and for specially crafted URLs it may + // get the host wrong; for safety reasons we will later compare the found + // host with the one that will actually be loaded. + let matchedURL = url.match( + /^(([a-z]+:\/\/)(?:[^\/#?]+@)?)(\S+?)(?::\d+)?\s*(?:[\/#?]|$)/ + ); + if (!matchedURL) { + return null; + } + let [, preDomain, schemeWSlashes, domain] = matchedURL; + + // If the found host differs from the fixed URI one, we can't properly + // highlight it. To stay on the safe side, we clobber user's input with + // the fixed URI and apply highlight to that one instead. + let replaceUrl = false; + try { + replaceUrl = + Services.io.newURI("http://" + domain).displayHost != + uriInfo.fixedURI.displayHost; + } catch (ex) { + return null; + } + if (replaceUrl) { + if (this._inGetUrlMetaData) { + // Protect from infinite recursion. + return null; + } + try { + this._inGetUrlMetaData = true; + this.window.gBrowser.userTypedValue = null; + this.urlbarInput.setURI(uriInfo.fixedURI); + return this._getUrlMetaData(); + } finally { + this._inGetUrlMetaData = false; + } + } + + return (browser._urlMetaData.data = { + domain, + origin: uriInfo.fixedURI.host, + preDomain, + schemeWSlashes, + trimmedLength, + url, + }); + } + + _removeURLFormat() { + if (!this._formattingApplied) { + return; + } + let controller = this.urlbarInput.editor.selectionController; + let strikeOut = controller.getSelection(controller.SELECTION_URLSTRIKEOUT); + strikeOut.removeAllRanges(); + let selection = controller.getSelection(controller.SELECTION_URLSECONDARY); + selection.removeAllRanges(); + this._formatScheme(controller.SELECTION_URLSTRIKEOUT, true); + this._formatScheme(controller.SELECTION_URLSECONDARY, true); + this.inputField.style.setProperty("--urlbar-scheme-size", "0px"); + } + + /** + * If the input value is a URL and the input is not focused, this + * formatter method highlights the domain, and if mixed content is present, + * it crosses out the https scheme. It also ensures that the host is + * visible (not scrolled out of sight). + * + * @returns {boolean} + * True if formatting was applied and false if not. + */ + _formatURL() { + let urlMetaData = this._getUrlMetaData(); + if (!urlMetaData) { + return false; + } + + let { + domain, + origin, + preDomain, + schemeWSlashes, + trimmedLength, + url, + } = urlMetaData; + // We strip http, so we should not show the scheme box for it. + if (!lazy.UrlbarPrefs.get("trimURLs") || schemeWSlashes != "http://") { + this.scheme.value = schemeWSlashes; + this.inputField.style.setProperty( + "--urlbar-scheme-size", + schemeWSlashes.length + "ch" + ); + } + + this._ensureFormattedHostVisible(urlMetaData); + + if (!lazy.UrlbarPrefs.get("formatting.enabled")) { + return false; + } + + let editor = this.urlbarInput.editor; + let controller = editor.selectionController; + + this._formatScheme(controller.SELECTION_URLSECONDARY); + + let textNode = editor.rootElement.firstChild; + + // Strike out the "https" part if mixed active content is loaded. + if ( + this.urlbarInput.getAttribute("pageproxystate") == "valid" && + url.startsWith("https:") && + this.window.gBrowser.securityUI.state & + Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT + ) { + let range = this.document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 5); + let strikeOut = controller.getSelection( + controller.SELECTION_URLSTRIKEOUT + ); + strikeOut.addRange(range); + this._formatScheme(controller.SELECTION_URLSTRIKEOUT); + } + + let baseDomain = domain; + let subDomain = ""; + try { + baseDomain = Services.eTLD.getBaseDomainFromHost(origin); + if (!domain.endsWith(baseDomain)) { + // getBaseDomainFromHost converts its resultant to ACE. + let IDNService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + baseDomain = IDNService.convertACEtoUTF8(baseDomain); + } + } catch (e) {} + if (baseDomain != domain) { + subDomain = domain.slice(0, -baseDomain.length); + } + + let selection = controller.getSelection(controller.SELECTION_URLSECONDARY); + + let rangeLength = preDomain.length + subDomain.length - trimmedLength; + if (rangeLength) { + let range = this.document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, rangeLength); + selection.addRange(range); + } + + let startRest = preDomain.length + domain.length - trimmedLength; + if (startRest < url.length - trimmedLength) { + let range = this.document.createRange(); + range.setStart(textNode, startRest); + range.setEnd(textNode, url.length - trimmedLength); + selection.addRange(range); + } + + return true; + } + + _formatScheme(selectionType, clear) { + let editor = this.scheme.editor; + let controller = editor.selectionController; + let textNode = editor.rootElement.firstChild; + let selection = controller.getSelection(selectionType); + if (clear) { + selection.removeAllRanges(); + } else { + let r = this.document.createRange(); + r.setStart(textNode, 0); + r.setEnd(textNode, textNode.textContent.length); + selection.addRange(r); + } + } + + _removeSearchAliasFormat() { + if (!this._formattingApplied) { + return; + } + let selection = this.urlbarInput.editor.selectionController.getSelection( + Ci.nsISelectionController.SELECTION_FIND + ); + selection.removeAllRanges(); + } + + /** + * If the input value starts with an @engine search alias, this highlights it. + * + * @returns {boolean} + * True if formatting was applied and false if not. + */ + _formatSearchAlias() { + if (!lazy.UrlbarPrefs.get("formatting.enabled")) { + return false; + } + + let editor = this.urlbarInput.editor; + let textNode = editor.rootElement.firstChild; + let value = textNode.textContent; + let trimmedValue = value.trim(); + + if ( + !trimmedValue.startsWith("@") || + (this.urlbarInput.popup || this.urlbarInput.view).oneOffSearchButtons + .selectedButton + ) { + return false; + } + + let alias = this._getSearchAlias(); + if (!alias) { + return false; + } + + // Make sure the current input starts with the alias because it can change + // without the popup results changing. Most notably that happens when the + // user performs a search using an alias: The popup closes (preserving its + // results), the search results page loads, and the input value is set to + // the URL of the page. + if (trimmedValue != alias && !trimmedValue.startsWith(alias + " ")) { + return false; + } + + let index = value.indexOf(alias); + if (index < 0) { + return false; + } + + // We abuse the SELECTION_FIND selection type to do our highlighting. + // It's the only type that works with Selection.setColors(). + let selection = editor.selectionController.getSelection( + Ci.nsISelectionController.SELECTION_FIND + ); + + let range = this.document.createRange(); + range.setStart(textNode, index); + range.setEnd(textNode, index + alias.length); + selection.addRange(range); + + let fg = "#2362d7"; + let bg = "#d2e6fd"; + + // Selection.setColors() will swap the given foreground and background + // colors if it detects that the contrast between the background + // color and the frame color is too low. Normally we don't want that + // to happen; we want it to use our colors as given (even if setColors + // thinks the contrast is too low). But it's a nice feature for non- + // default themes, where the contrast between our background color and + // the input's frame color might actually be too low. We can + // (hackily) force setColors to use our colors as given by passing + // them as the alternate colors. Otherwise, allow setColors to swap + // them, which we can do by passing "currentColor". See + // nsTextPaintStyle::GetHighlightColors for details. + if ( + this.document.documentElement.hasAttribute("lwtheme") || + this.window.matchMedia("(prefers-contrast)").matches + ) { + // non-default theme(s) + selection.setColors(fg, bg, "currentColor", "currentColor"); + } else { + // default themes + selection.setColors(fg, bg, fg, bg); + } + + return true; + } + + _getSearchAlias() { + // To determine whether the input contains a valid alias, check if the + // selected result is a search result with an alias. If there is no selected + // result, we check the first result in the view, for cases when we do not + // highlight token alias results. The selected result is null when the popup + // is closed, but we want to continue highlighting the alias when the popup + // is closed, and that's why we keep around the previously selected result + // in _selectedResult. + this._selectedResult = + this.urlbarInput.view.selectedResult || + this.urlbarInput.view.getResultAtIndex(0) || + this._selectedResult; + + if ( + this._selectedResult && + this._selectedResult.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH + ) { + return this._selectedResult.payload.keyword || null; + } + return null; + } + + /** + * Passes DOM events to the _on_<event type> methods. + * + * @param {Event} event + * DOM event. + */ + handleEvent(event) { + let methodName = "_on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized UrlbarValueFormatter event: " + event.type); + } + } + + _on_resize(event) { + if (event.target != this.window) { + return; + } + // Make sure the host remains visible in the input field when the window is + // resized. We don't want to hurt resize performance though, so do this + // only after resize events have stopped and a small timeout has elapsed. + if (this._resizeThrottleTimeout) { + this.window.clearTimeout(this._resizeThrottleTimeout); + } + this._resizeThrottleTimeout = this.window.setTimeout(() => { + this._resizeThrottleTimeout = null; + this._ensureFormattedHostVisible(); + }, 100); + } +} |