diff options
Diffstat (limited to '')
42 files changed, 23605 insertions, 0 deletions
diff --git a/toolkit/content/widgets.css b/toolkit/content/widgets.css new file mode 100644 index 0000000000..28c8753f50 --- /dev/null +++ b/toolkit/content/widgets.css @@ -0,0 +1,25 @@ +/* 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/. */ + +/* ===== widgets.css ===================================================== + == Styles ported from XBL <resources>, loaded by "global.css". + ======================================================================= */ + +@import url("chrome://global/content/autocomplete.css"); +@import url("chrome://global/skin/autocomplete.css"); +@import url("chrome://global/skin/button.css"); +@import url("chrome://global/skin/checkbox.css"); +@import url("chrome://global/skin/findBar.css"); +@import url("chrome://global/skin/menu.css"); +@import url("chrome://global/skin/notification.css"); +@import url("chrome://global/skin/numberinput.css"); +@import url("chrome://global/skin/popup.css"); +@import url("chrome://global/skin/popupnotification.css"); +@import url("chrome://global/skin/radio.css"); +@import url("chrome://global/skin/richlistbox.css"); +@import url("chrome://global/skin/splitter.css"); +@import url("chrome://global/skin/tabbox.css"); +@import url("chrome://global/skin/toolbar.css"); +@import url("chrome://global/skin/toolbarbutton.css"); +@import url("chrome://global/skin/tree.css"); diff --git a/toolkit/content/widgets/arrowscrollbox.js b/toolkit/content/widgets/arrowscrollbox.js new file mode 100644 index 0000000000..eda0c37d0f --- /dev/null +++ b/toolkit/content/widgets/arrowscrollbox.js @@ -0,0 +1,864 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + class MozArrowScrollbox extends MozElements.BaseControl { + static get inheritedAttributes() { + return { + "#scrollbutton-up": "disabled=scrolledtostart", + ".scrollbox-clip": "orient", + scrollbox: "orient,align,pack,dir,smoothscroll", + "#scrollbutton-down": "disabled=scrolledtoend", + }; + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/toolbarbutton.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/arrowscrollbox.css"/> + <toolbarbutton id="scrollbutton-up" part="scrollbutton-up"/> + <spacer part="overflow-start-indicator"/> + <box class="scrollbox-clip" part="scrollbox-clip" flex="1"> + <scrollbox part="scrollbox" flex="1"> + <html:slot/> + </scrollbox> + </box> + <spacer part="overflow-end-indicator"/> + <toolbarbutton id="scrollbutton-down" part="scrollbutton-down"/> + `; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.fragment); + + this.scrollbox = this.shadowRoot.querySelector("scrollbox"); + this._scrollButtonUp = this.shadowRoot.getElementById("scrollbutton-up"); + this._scrollButtonDown = this.shadowRoot.getElementById( + "scrollbutton-down" + ); + + this._arrowScrollAnim = { + scrollbox: this, + requestHandle: 0, + /* 0 indicates there is no pending request */ + start: function arrowSmoothScroll_start() { + this.lastFrameTime = window.performance.now(); + if (!this.requestHandle) { + this.requestHandle = window.requestAnimationFrame( + this.sample.bind(this) + ); + } + }, + stop: function arrowSmoothScroll_stop() { + window.cancelAnimationFrame(this.requestHandle); + this.requestHandle = 0; + }, + sample: function arrowSmoothScroll_handleEvent(timeStamp) { + const scrollIndex = this.scrollbox._scrollIndex; + const timePassed = timeStamp - this.lastFrameTime; + this.lastFrameTime = timeStamp; + + const scrollDelta = 0.5 * timePassed * scrollIndex; + this.scrollbox.scrollByPixels(scrollDelta, true); + this.requestHandle = window.requestAnimationFrame( + this.sample.bind(this) + ); + }, + }; + + this._scrollIndex = 0; + this._scrollIncrement = null; + this._ensureElementIsVisibleAnimationFrame = 0; + this._prevMouseScrolls = [null, null]; + this._touchStart = -1; + this._scrollButtonUpdatePending = false; + this._isScrolling = false; + this._destination = 0; + this._direction = 0; + + this.addEventListener("wheel", this.on_wheel); + this.addEventListener("touchstart", this.on_touchstart); + this.addEventListener("touchmove", this.on_touchmove); + this.addEventListener("touchend", this.on_touchend); + this.shadowRoot.addEventListener("click", this.on_click.bind(this)); + this.shadowRoot.addEventListener( + "mousedown", + this.on_mousedown.bind(this) + ); + this.shadowRoot.addEventListener( + "mouseover", + this.on_mouseover.bind(this) + ); + this.shadowRoot.addEventListener("mouseup", this.on_mouseup.bind(this)); + this.shadowRoot.addEventListener("mouseout", this.on_mouseout.bind(this)); + + // These events don't get retargeted outside of the shadow root, but + // some callers like tests wait for these events. So run handlers + // and then retarget events from the scrollbox to the host. + this.scrollbox.addEventListener( + "underflow", + event => { + this.on_underflow(event); + this.dispatchEvent(new Event("underflow")); + }, + true + ); + this.scrollbox.addEventListener( + "overflow", + event => { + this.on_overflow(event); + this.dispatchEvent(new Event("overflow")); + }, + true + ); + this.scrollbox.addEventListener("scroll", event => { + this.on_scroll(event); + this.dispatchEvent(new Event("scroll")); + }); + + this.scrollbox.addEventListener("scrollend", event => { + this.on_scrollend(event); + this.dispatchEvent(new Event("scrollend")); + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + if (!this.hasAttribute("smoothscroll")) { + this.smoothScroll = Services.prefs.getBoolPref( + "toolkit.scrollbox.smoothScroll", + true + ); + } + + this.removeAttribute("overflowing"); + this.initializeAttributeInheritance(); + this._updateScrollButtonsDisabledState(); + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment( + this.markup + ); + } + return document.importNode(this.constructor._fragment, true); + } + + get _clickToScroll() { + return this.hasAttribute("clicktoscroll"); + } + + get _scrollDelay() { + if (this._clickToScroll) { + return Services.prefs.getIntPref( + "toolkit.scrollbox.clickToScroll.scrollDelay", + 150 + ); + } + + // Use the same REPEAT_DELAY as "nsRepeatService.h". + return /Mac/.test(navigator.platform) ? 25 : 50; + } + + get scrollIncrement() { + if (this._scrollIncrement === null) { + this._scrollIncrement = Services.prefs.getIntPref( + "toolkit.scrollbox.scrollIncrement", + 20 + ); + } + return this._scrollIncrement; + } + + set smoothScroll(val) { + this.setAttribute("smoothscroll", !!val); + return val; + } + + get smoothScroll() { + return this.getAttribute("smoothscroll") == "true"; + } + + get scrollClientRect() { + return this.scrollbox.getBoundingClientRect(); + } + + get scrollClientSize() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.clientHeight + : this.scrollbox.clientWidth; + } + + get scrollSize() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.scrollHeight + : this.scrollbox.scrollWidth; + } + + get lineScrollAmount() { + // line scroll amout should be the width (at horizontal scrollbox) or + // the height (at vertical scrollbox) of the scrolled elements. + // However, the elements may have different width or height. So, + // for consistent speed, let's use avalage with of the elements. + var elements = this._getScrollableElements(); + return elements.length && this.scrollSize / elements.length; + } + + get scrollPosition() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.scrollTop + : this.scrollbox.scrollLeft; + } + + get startEndProps() { + if (!this._startEndProps) { + this._startEndProps = + this.getAttribute("orient") == "vertical" + ? ["top", "bottom"] + : ["left", "right"]; + } + return this._startEndProps; + } + + get isRTLScrollbox() { + if (!this._isRTLScrollbox) { + this._isRTLScrollbox = + this.getAttribute("orient") != "vertical" && + document.defaultView.getComputedStyle(this.scrollbox).direction == + "rtl"; + } + return this._isRTLScrollbox; + } + + _onButtonClick(event) { + if (this._clickToScroll) { + this._distanceScroll(event); + } + } + + _onButtonMouseDown(event, index) { + if (this._clickToScroll && event.button == 0) { + this._startScroll(index); + } + } + + _onButtonMouseUp(event) { + if (this._clickToScroll && event.button == 0) { + this._stopScroll(); + } + } + + _onButtonMouseOver(index) { + if (this._clickToScroll) { + this._continueScroll(index); + } else { + this._startScroll(index); + } + } + + _onButtonMouseOut() { + if (this._clickToScroll) { + this._pauseScroll(); + } else { + this._stopScroll(); + } + } + + _boundsWithoutFlushing(element) { + if (!("_DOMWindowUtils" in this)) { + this._DOMWindowUtils = window.windowUtils; + } + + return this._DOMWindowUtils + ? this._DOMWindowUtils.getBoundsWithoutFlushing(element) + : element.getBoundingClientRect(); + } + + _canScrollToElement(element) { + if (element.hidden) { + return false; + } + + // See if the element is hidden via CSS without the hidden attribute. + // If we get only zeros for the client rect, this means the element + // is hidden. As a performance optimization, we don't flush layout + // here which means that on the fly changes aren't fully supported. + let rect = this._boundsWithoutFlushing(element); + return !!(rect.top || rect.left || rect.width || rect.height); + } + + ensureElementIsVisible(element, aInstant) { + if (!this._canScrollToElement(element)) { + return; + } + + if (this._ensureElementIsVisibleAnimationFrame) { + window.cancelAnimationFrame(this._ensureElementIsVisibleAnimationFrame); + } + this._ensureElementIsVisibleAnimationFrame = window.requestAnimationFrame( + () => { + element.scrollIntoView({ + block: "nearest", + behavior: aInstant ? "instant" : "auto", + }); + this._ensureElementIsVisibleAnimationFrame = 0; + } + ); + } + + scrollByIndex(index, aInstant) { + if (index == 0) { + return; + } + + var rect = this.scrollClientRect; + var [start, end] = this.startEndProps; + var x = index > 0 ? rect[end] + 1 : rect[start] - 1; + var nextElement = this._elementFromPoint(x, index); + if (!nextElement) { + return; + } + + var targetElement; + if (this.isRTLScrollbox) { + index *= -1; + } + while (index < 0 && nextElement) { + if (this._canScrollToElement(nextElement)) { + targetElement = nextElement; + } + nextElement = nextElement.previousElementSibling; + index++; + } + while (index > 0 && nextElement) { + if (this._canScrollToElement(nextElement)) { + targetElement = nextElement; + } + nextElement = nextElement.nextElementSibling; + index--; + } + if (!targetElement) { + return; + } + + this.ensureElementIsVisible(targetElement, aInstant); + } + + _getScrollableElements() { + let nodes = this.children; + if (nodes.length == 1) { + let node = nodes[0]; + if ( + node.localName == "slot" && + node.namespaceURI == "http://www.w3.org/1999/xhtml" + ) { + nodes = node.getRootNode().host.children; + } + } + return Array.prototype.filter.call(nodes, this._canScrollToElement, this); + } + + _elementFromPoint(aX, aPhysicalScrollDir) { + var elements = this._getScrollableElements(); + if (!elements.length) { + return null; + } + + if (this.isRTLScrollbox) { + elements.reverse(); + } + + var [start, end] = this.startEndProps; + var low = 0; + var high = elements.length - 1; + + if ( + aX < elements[low].getBoundingClientRect()[start] || + aX > elements[high].getBoundingClientRect()[end] + ) { + return null; + } + + var mid, rect; + while (low <= high) { + mid = Math.floor((low + high) / 2); + rect = elements[mid].getBoundingClientRect(); + if (rect[start] > aX) { + high = mid - 1; + } else if (rect[end] < aX) { + low = mid + 1; + } else { + return elements[mid]; + } + } + + // There's no element at the requested coordinate, but the algorithm + // from above yields an element next to it, in a random direction. + // The desired scrolling direction leads to the correct element. + + if (!aPhysicalScrollDir) { + return null; + } + + if (aPhysicalScrollDir < 0 && rect[start] > aX) { + mid = Math.max(mid - 1, 0); + } else if (aPhysicalScrollDir > 0 && rect[end] < aX) { + mid = Math.min(mid + 1, elements.length - 1); + } + + return elements[mid]; + } + + _startScroll(index) { + if (this.isRTLScrollbox) { + index *= -1; + } + + if (this._clickToScroll) { + this._scrollIndex = index; + this._mousedown = true; + + if (this.smoothScroll) { + this._arrowScrollAnim.start(); + return; + } + } + + if (!this._scrollTimer) { + this._scrollTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + } else { + this._scrollTimer.cancel(); + } + + let callback; + if (this._clickToScroll) { + callback = () => { + if (!document && this._scrollTimer) { + this._scrollTimer.cancel(); + } + this.scrollByIndex(this._scrollIndex); + }; + } else { + callback = () => this.scrollByPixels(this.scrollIncrement * index); + } + + this._scrollTimer.initWithCallback( + callback, + this._scrollDelay, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + + callback(); + } + + _stopScroll() { + if (this._scrollTimer) { + this._scrollTimer.cancel(); + } + + if (this._clickToScroll) { + this._mousedown = false; + if (!this._scrollIndex || !this.smoothScroll) { + return; + } + + this.scrollByIndex(this._scrollIndex); + this._scrollIndex = 0; + + this._arrowScrollAnim.stop(); + } + } + + _pauseScroll() { + if (this._mousedown) { + this._stopScroll(); + this._mousedown = true; + document.addEventListener("mouseup", this); + document.addEventListener("blur", this, true); + } + } + + _continueScroll(index) { + if (this._mousedown) { + this._startScroll(index); + } + } + + _distanceScroll(aEvent) { + if (aEvent.detail < 2 || aEvent.detail > 3) { + return; + } + + var scrollBack = aEvent.originalTarget == this._scrollButtonUp; + var scrollLeftOrUp = this.isRTLScrollbox ? !scrollBack : scrollBack; + var targetElement; + + if (aEvent.detail == 2) { + // scroll by the size of the scrollbox + let [start, end] = this.startEndProps; + let x; + if (scrollLeftOrUp) { + x = this.scrollClientRect[start] - this.scrollClientSize; + } else { + x = this.scrollClientRect[end] + this.scrollClientSize; + } + targetElement = this._elementFromPoint(x, scrollLeftOrUp ? -1 : 1); + + // the next partly-hidden element will become fully visible, + // so don't scroll too far + if (targetElement) { + targetElement = scrollBack + ? targetElement.nextElementSibling + : targetElement.previousElementSibling; + } + } + + if (!targetElement) { + // scroll to the first resp. last element + let elements = this._getScrollableElements(); + targetElement = scrollBack + ? elements[0] + : elements[elements.length - 1]; + } + + this.ensureElementIsVisible(targetElement); + } + + handleEvent(aEvent) { + if ( + aEvent.type == "mouseup" || + (aEvent.type == "blur" && aEvent.target == document) + ) { + this._mousedown = false; + document.removeEventListener("mouseup", this); + document.removeEventListener("blur", this, true); + } + } + + scrollByPixels(aPixels, aInstant) { + let scrollOptions = { behavior: aInstant ? "instant" : "auto" }; + scrollOptions[this.startEndProps[0]] = aPixels; + this.scrollbox.scrollBy(scrollOptions); + } + + _updateScrollButtonsDisabledState() { + if (!this.hasAttribute("overflowing")) { + this.setAttribute("scrolledtoend", "true"); + this.setAttribute("scrolledtostart", "true"); + return; + } + + if (this._scrollButtonUpdatePending) { + return; + } + this._scrollButtonUpdatePending = true; + + // Wait until after the next paint to get current layout data from + // getBoundsWithoutFlushing. + window.requestAnimationFrame(() => { + setTimeout(() => { + if (!this.isConnected) { + // We've been destroyed in the meantime. + return; + } + + this._scrollButtonUpdatePending = false; + + let scrolledToStart = false; + let scrolledToEnd = false; + + if (!this.hasAttribute("overflowing")) { + scrolledToStart = true; + scrolledToEnd = true; + } else { + let [leftOrTop, rightOrBottom] = this.startEndProps; + let leftOrTopEdge = ele => + Math.round(this._boundsWithoutFlushing(ele)[leftOrTop]); + let rightOrBottomEdge = ele => + Math.round(this._boundsWithoutFlushing(ele)[rightOrBottom]); + + let elements = this._getScrollableElements(); + let [leftOrTopElement, rightOrBottomElement] = [ + elements[0], + elements[elements.length - 1], + ]; + if (this.isRTLScrollbox) { + [leftOrTopElement, rightOrBottomElement] = [ + rightOrBottomElement, + leftOrTopElement, + ]; + } + + if ( + leftOrTopElement && + leftOrTopEdge(leftOrTopElement) >= leftOrTopEdge(this.scrollbox) + ) { + scrolledToStart = !this.isRTLScrollbox; + scrolledToEnd = this.isRTLScrollbox; + } else if ( + rightOrBottomElement && + rightOrBottomEdge(rightOrBottomElement) <= + rightOrBottomEdge(this.scrollbox) + ) { + scrolledToStart = this.isRTLScrollbox; + scrolledToEnd = !this.isRTLScrollbox; + } + } + + if (scrolledToEnd) { + this.setAttribute("scrolledtoend", "true"); + } else { + this.removeAttribute("scrolledtoend"); + } + + if (scrolledToStart) { + this.setAttribute("scrolledtostart", "true"); + } else { + this.removeAttribute("scrolledtostart"); + } + }, 0); + }); + } + + disconnectedCallback() { + // Release timer to avoid reference cycles. + if (this._scrollTimer) { + this._scrollTimer.cancel(); + this._scrollTimer = null; + } + } + + on_wheel(event) { + // Don't consume the event if we can't scroll. + if (!this.hasAttribute("overflowing")) { + return; + } + + let doScroll = false; + let instant; + let scrollAmount = 0; + if (this.getAttribute("orient") == "vertical") { + doScroll = true; + if (event.deltaMode == event.DOM_DELTA_PIXEL) { + scrollAmount = event.deltaY; + } else if (event.deltaMode == event.DOM_DELTA_PAGE) { + scrollAmount = event.deltaY * this.scrollClientSize; + } else { + scrollAmount = event.deltaY * this.lineScrollAmount; + } + } else { + // We allow vertical scrolling to scroll a horizontal scrollbox + // because many users have a vertical scroll wheel but no + // horizontal support. + // Because of this, we need to avoid scrolling chaos on trackpads + // and mouse wheels that support simultaneous scrolling in both axes. + // We do this by scrolling only when the last two scroll events were + // on the same axis as the current scroll event. + // For diagonal scroll events we only respect the dominant axis. + let isVertical = Math.abs(event.deltaY) > Math.abs(event.deltaX); + let delta = isVertical ? event.deltaY : event.deltaX; + let scrollByDelta = isVertical && this.isRTLScrollbox ? -delta : delta; + + if (this._prevMouseScrolls.every(prev => prev == isVertical)) { + doScroll = true; + if (event.deltaMode == event.DOM_DELTA_PIXEL) { + scrollAmount = scrollByDelta; + instant = true; + } else if (event.deltaMode == event.DOM_DELTA_PAGE) { + scrollAmount = scrollByDelta * this.scrollClientSize; + } else { + scrollAmount = scrollByDelta * this.lineScrollAmount; + } + } + + if (this._prevMouseScrolls.length > 1) { + this._prevMouseScrolls.shift(); + } + this._prevMouseScrolls.push(isVertical); + } + + if (doScroll) { + let direction = scrollAmount < 0 ? -1 : 1; + let startPos = this.scrollPosition; + + if (!this._isScrolling || this._direction != direction) { + this._destination = startPos + scrollAmount; + this._direction = direction; + } else { + // We were already in the process of scrolling in this direction + this._destination = this._destination + scrollAmount; + scrollAmount = this._destination - startPos; + } + this.scrollByPixels(scrollAmount, instant); + } + + event.stopPropagation(); + event.preventDefault(); + } + + on_touchstart(event) { + if (event.touches.length > 1) { + // Multiple touch points detected, abort. In particular this aborts + // the panning gesture when the user puts a second finger down after + // already panning with one finger. Aborting at this point prevents + // the pan gesture from being resumed until all fingers are lifted + // (as opposed to when the user is back down to one finger). + this._touchStart = -1; + } else { + this._touchStart = + this.getAttribute("orient") == "vertical" + ? event.touches[0].screenY + : event.touches[0].screenX; + } + } + + on_touchmove(event) { + if (event.touches.length == 1 && this._touchStart >= 0) { + var touchPoint = + this.getAttribute("orient") == "vertical" + ? event.touches[0].screenY + : event.touches[0].screenX; + var delta = this._touchStart - touchPoint; + if (Math.abs(delta) > 0) { + this.scrollByPixels(delta, true); + this._touchStart = touchPoint; + } + event.preventDefault(); + } + } + + on_touchend(event) { + this._touchStart = -1; + } + + on_underflow(event) { + // Ignore underflow events: + // - from nested scrollable elements + // - corresponding to an overflow event that we ignored + if (event.target != this.scrollbox || !this.hasAttribute("overflowing")) { + return; + } + + // Ignore events that doesn't match our orientation. + // Scrollport event orientation: + // 0: vertical + // 1: horizontal + // 2: both + if (this.getAttribute("orient") == "vertical") { + if (event.detail == 1) { + return; + } + } else if (event.detail == 0) { + // horizontal scrollbox + return; + } + + this.removeAttribute("overflowing"); + this._updateScrollButtonsDisabledState(); + } + + on_overflow(event) { + // Ignore overflow events: + // - from nested scrollable elements + if (event.target != this.scrollbox) { + return; + } + + // Ignore events that doesn't match our orientation. + // Scrollport event orientation: + // 0: vertical + // 1: horizontal + // 2: both + if (this.getAttribute("orient") == "vertical") { + if (event.detail == 1) { + return; + } + } else if (event.detail == 0) { + // horizontal scrollbox + return; + } + + this.setAttribute("overflowing", "true"); + this._updateScrollButtonsDisabledState(); + } + + on_scroll(event) { + this._isScrolling = true; + this._updateScrollButtonsDisabledState(); + } + + on_scrollend(event) { + this._isScrolling = false; + this._destination = 0; + this._direction = 0; + } + + on_click(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonClick(event); + } + + on_mousedown(event) { + if (event.originalTarget == this._scrollButtonUp) { + this._onButtonMouseDown(event, -1); + } + if (event.originalTarget == this._scrollButtonDown) { + this._onButtonMouseDown(event, 1); + } + } + + on_mouseup(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonMouseUp(event); + } + + on_mouseover(event) { + if (event.originalTarget == this._scrollButtonUp) { + this._onButtonMouseOver(-1); + } + if (event.originalTarget == this._scrollButtonDown) { + this._onButtonMouseOver(1); + } + } + + on_mouseout(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonMouseOut(); + } + } + + customElements.define("arrowscrollbox", MozArrowScrollbox); +} diff --git a/toolkit/content/widgets/autocomplete-input.js b/toolkit/content/widgets/autocomplete-input.js new file mode 100644 index 0000000000..935590cd14 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-input.js @@ -0,0 +1,674 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" + ); + + class AutocompleteInput extends HTMLInputElement { + constructor() { + super(); + + this.popupSelectedIndex = -1; + + ChromeUtils.defineModuleGetter( + this, + "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm" + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "disablePopupAutohide", + "ui.popup.disable_autohide", + false + ); + + this.addEventListener("input", event => { + this.onInput(event); + }); + + this.addEventListener("keydown", event => this.handleKeyDown(event)); + + this.addEventListener( + "compositionstart", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleStartComposition(); + } + }, + true + ); + + this.addEventListener( + "compositionend", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleEndComposition(); + } + }, + true + ); + + this.addEventListener( + "focus", + event => { + this.attachController(); + if ( + window.gBrowser && + window.gBrowser.selectedBrowser.hasAttribute("usercontextid") + ) { + this.userContextId = parseInt( + window.gBrowser.selectedBrowser.getAttribute("usercontextid") + ); + } else { + this.userContextId = 0; + } + }, + true + ); + + this.addEventListener( + "blur", + event => { + if (!this._dontBlur) { + if (this.forceComplete && this.mController.matchCount >= 1) { + // If forceComplete is requested, we need to call the enter processing + // on blur so the input will be forced to the closest match. + // Thunderbird is the only consumer of forceComplete and this is used + // to force an recipient's email to the exact address book entry. + this.mController.handleEnter(true); + } + if (!this.ignoreBlurWhileSearching) { + this._dontClosePopup = this.disablePopupAutohide; + this.detachController(); + } + } + }, + true + ); + } + + connectedCallback() { + this.setAttribute("is", "autocomplete-input"); + this.setAttribute("autocomplete", "off"); + + this.mController = Cc[ + "@mozilla.org/autocomplete/controller;1" + ].getService(Ci.nsIAutoCompleteController); + this.mSearchNames = null; + this.mIgnoreInput = false; + this.noRollupOnEmptySearch = false; + + this._popup = null; + + this.nsIAutocompleteInput = this.getCustomInterfaceCallback( + Ci.nsIAutoCompleteInput + ); + + this.valueIsTyped = false; + } + + get popup() { + // Memoize the result in a field rather than replacing this property, + // so that it can be reset along with the binding. + if (this._popup) { + return this._popup; + } + + let popup = null; + let popupId = this.getAttribute("autocompletepopup"); + if (popupId) { + popup = document.getElementById(popupId); + } + + /* This path is only used in tests, we have the <popupset> and <panel> + in document for other usages */ + if (!popup) { + popup = document.createXULElement("panel", { + is: "autocomplete-richlistbox-popup", + }); + popup.setAttribute("type", "autocomplete-richlistbox"); + popup.setAttribute("noautofocus", "true"); + + if (!this._popupset) { + this._popupset = document.createXULElement("popupset"); + document.documentElement.appendChild(this._popupset); + } + + this._popupset.appendChild(popup); + } + popup.mInput = this; + + return (this._popup = popup); + } + + get popupElement() { + return this.popup; + } + + get controller() { + return this.mController; + } + + set popupOpen(val) { + if (val) { + this.openPopup(); + } else { + this.closePopup(); + } + } + + get popupOpen() { + return this.popup.popupOpen; + } + + set disableAutoComplete(val) { + this.setAttribute("disableautocomplete", val); + return val; + } + + get disableAutoComplete() { + return this.getAttribute("disableautocomplete") == "true"; + } + + set completeDefaultIndex(val) { + this.setAttribute("completedefaultindex", val); + return val; + } + + get completeDefaultIndex() { + return this.getAttribute("completedefaultindex") == "true"; + } + + set completeSelectedIndex(val) { + this.setAttribute("completeselectedindex", val); + return val; + } + + get completeSelectedIndex() { + return this.getAttribute("completeselectedindex") == "true"; + } + + set forceComplete(val) { + this.setAttribute("forcecomplete", val); + return val; + } + + get forceComplete() { + return this.getAttribute("forcecomplete") == "true"; + } + + set minResultsForPopup(val) { + this.setAttribute("minresultsforpopup", val); + return val; + } + + get minResultsForPopup() { + var m = parseInt(this.getAttribute("minresultsforpopup")); + return isNaN(m) ? 1 : m; + } + + set timeout(val) { + this.setAttribute("timeout", val); + return val; + } + + get timeout() { + var t = parseInt(this.getAttribute("timeout")); + return isNaN(t) ? 50 : t; + } + + set searchParam(val) { + this.setAttribute("autocompletesearchparam", val); + return val; + } + + get searchParam() { + return this.getAttribute("autocompletesearchparam") || ""; + } + + get searchCount() { + this.initSearchNames(); + return this.mSearchNames.length; + } + + get inPrivateContext() { + return this.PrivateBrowsingUtils.isWindowPrivate(window); + } + + get noRollupOnCaretMove() { + return this.popup.getAttribute("norolluponanchor") == "true"; + } + + set textValue(val) { + // "input" event is automatically dispatched by the editor if + // necessary. + this._setValueInternal(val, true); + + return this.value; + } + + get textValue() { + return this.value; + } + /** + * =================== nsIDOMXULMenuListElement =================== + */ + get editable() { + return true; + } + + set crop(val) { + return false; + } + + get crop() { + return false; + } + + set open(val) { + if (val) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + get open() { + return this.getAttribute("open") == "true"; + } + + set value(val) { + return this._setValueInternal(val, false); + } + + get value() { + return super.value; + } + + get focused() { + return this === document.activeElement; + } + /** + * maximum number of rows to display at a time when opening the popup normally + * (e.g., focus element and press the down arrow) + */ + set maxRows(val) { + this.setAttribute("maxrows", val); + return val; + } + + get maxRows() { + return parseInt(this.getAttribute("maxrows")) || 0; + } + /** + * maximum number of rows to display at a time when opening the popup by + * clicking the dropmarker (for inputs that have one) + */ + set maxdropmarkerrows(val) { + this.setAttribute("maxdropmarkerrows", val); + return val; + } + + get maxdropmarkerrows() { + return parseInt(this.getAttribute("maxdropmarkerrows"), 10) || 14; + } + /** + * option to allow scrolling through the list via the tab key, rather than + * tab moving focus out of the textbox + */ + set tabScrolling(val) { + this.setAttribute("tabscrolling", val); + return val; + } + + get tabScrolling() { + return this.getAttribute("tabscrolling") == "true"; + } + /** + * option to completely ignore any blur events while searches are + * still going on. + */ + set ignoreBlurWhileSearching(val) { + this.setAttribute("ignoreblurwhilesearching", val); + return val; + } + + get ignoreBlurWhileSearching() { + return this.getAttribute("ignoreblurwhilesearching") == "true"; + } + /** + * option to highlight entries that don't have any matches + */ + set highlightNonMatches(val) { + this.setAttribute("highlightnonmatches", val); + return val; + } + + get highlightNonMatches() { + return this.getAttribute("highlightnonmatches") == "true"; + } + + getSearchAt(aIndex) { + this.initSearchNames(); + return this.mSearchNames[aIndex]; + } + + setTextValueWithReason(aValue, aReason) { + this.textValue = aValue; + } + + selectTextRange(aStartIndex, aEndIndex) { + super.setSelectionRange(aStartIndex, aEndIndex); + } + + onSearchBegin() { + if (this.popup && typeof this.popup.onSearchBegin == "function") { + this.popup.onSearchBegin(); + } + } + + onSearchComplete() { + if (this.mController.matchCount == 0) { + this.setAttribute("nomatch", "true"); + } else { + this.removeAttribute("nomatch"); + } + + if (this.ignoreBlurWhileSearching && !this.focused) { + this.handleEnter(); + this.detachController(); + } + } + + onTextEntered(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textEntered", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + onTextReverted(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textReverted", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + /** + * =================== PRIVATE MEMBERS =================== + */ + + /* + * ::::::::::::: autocomplete controller ::::::::::::: + */ + + attachController() { + this.mController.input = this.nsIAutocompleteInput; + } + + detachController() { + if ( + this.mController.input && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.input = null; + } + } + + /** + * ::::::::::::: popup opening ::::::::::::: + */ + openPopup() { + if (this.focused) { + this.popup.openAutocompletePopup(this.nsIAutocompleteInput, this); + } + } + + closePopup() { + if (this._dontClosePopup) { + delete this._dontClosePopup; + return; + } + this.popup.closePopup(); + } + + showHistoryPopup() { + // Store our "normal" maxRows on the popup, so that it can reset the + // value when the popup is hidden. + this.popup._normalMaxRows = this.maxRows; + + // Temporarily change our maxRows, since we want the dropdown to be a + // different size in this case. The popup's popupshowing/popuphiding + // handlers will take care of resetting this. + this.maxRows = this.maxdropmarkerrows; + + // Ensure that we have focus. + if (!this.focused) { + this.focus(); + } + this.attachController(); + this.mController.startSearch(""); + } + + toggleHistoryPopup() { + if (!this.popup.popupOpen) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + handleKeyDown(aEvent) { + // Re: urlbarDeferred, see the comment in urlbarBindings.xml. + if (aEvent.defaultPrevented && !aEvent.urlbarDeferred) { + return false; + } + + if ( + typeof this.onBeforeHandleKeyDown == "function" && + this.onBeforeHandleKeyDown(aEvent) + ) { + return true; + } + + const isMac = AppConstants.platform == "macosx"; + var cancel = false; + + // Catch any keys that could potentially move the caret. Ctrl can be + // used in combination with these keys on Windows and Linux; and Alt + // can be used on OS X, so make sure the unused one isn't used. + let metaKey = isMac ? aEvent.ctrlKey : aEvent.altKey; + if (!metaKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_LEFT: + case KeyEvent.DOM_VK_RIGHT: + case KeyEvent.DOM_VK_HOME: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle keys that are not part of a keyboard shortcut (no Ctrl or Alt) + if (!aEvent.ctrlKey && !aEvent.altKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_TAB: + if (this.tabScrolling && this.popup.popupOpen) { + cancel = this.mController.handleKeyNavigation( + aEvent.shiftKey ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN + ); + } else if (this.forceComplete && this.mController.matchCount >= 1) { + this.mController.handleTab(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle readline/emacs-style navigation bindings on Mac. + if ( + isMac && + this.popup.popupOpen && + aEvent.ctrlKey && + (aEvent.key === "n" || aEvent.key === "p") + ) { + const effectiveKey = + aEvent.key === "p" ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN; + cancel = this.mController.handleKeyNavigation(effectiveKey); + } + + // Handle keys we know aren't part of a shortcut, even with Alt or + // Ctrl. + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + cancel = this.mController.handleEscape(); + break; + case KeyEvent.DOM_VK_RETURN: + if (isMac) { + // Prevent the default action, since it will beep on Mac + if (aEvent.metaKey) { + aEvent.preventDefault(); + } + } + if (this.popup.selectedIndex >= 0) { + this.popupSelectedIndex = this.popup.selectedIndex; + } + cancel = this.handleEnter(aEvent); + break; + case KeyEvent.DOM_VK_DELETE: + if (isMac && !aEvent.shiftKey) { + break; + } + cancel = this.handleDelete(); + break; + case KeyEvent.DOM_VK_BACK_SPACE: + if (isMac && aEvent.shiftKey) { + cancel = this.handleDelete(); + } + break; + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_UP: + if (aEvent.altKey) { + this.toggleHistoryPopup(); + } + break; + case KeyEvent.DOM_VK_F4: + if (!isMac) { + this.toggleHistoryPopup(); + } + break; + } + + if (cancel) { + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + + return true; + } + + handleEnter(event) { + return this.mController.handleEnter(false, event || null); + } + + handleDelete() { + return this.mController.handleDelete(); + } + + /** + * ::::::::::::: miscellaneous ::::::::::::: + */ + initSearchNames() { + if (!this.mSearchNames) { + var names = this.getAttribute("autocompletesearch"); + if (!names) { + this.mSearchNames = []; + } else { + this.mSearchNames = names.split(" "); + } + } + } + + _focus() { + this._dontBlur = true; + this.focus(); + this._dontBlur = false; + } + + resetActionType() { + if (this.mIgnoreInput) { + return; + } + this.removeAttribute("actiontype"); + } + + _setValueInternal(value, isUserInput) { + this.mIgnoreInput = true; + + if (typeof this.onBeforeValueSet == "function") { + value = this.onBeforeValueSet(value); + } + + this.valueIsTyped = false; + if (isUserInput) { + super.setUserInput(value); + } else { + super.value = value; + } + + this.mIgnoreInput = false; + var event = document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + super.dispatchEvent(event); + return value; + } + + onInput(aEvent) { + if ( + !this.mIgnoreInput && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.valueIsTyped = true; + this.mController.handleText(); + } + this.resetActionType(); + } + } + + MozHTMLElement.implementCustomInterface(AutocompleteInput, [ + Ci.nsIAutoCompleteInput, + Ci.nsIDOMXULMenuListElement, + ]); + customElements.define("autocomplete-input", AutocompleteInput, { + extends: "input", + }); +} diff --git a/toolkit/content/widgets/autocomplete-popup.js b/toolkit/content/widgets/autocomplete-popup.js new file mode 100644 index 0000000000..ebd549aaa7 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-popup.js @@ -0,0 +1,621 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const MozPopupElement = MozElements.MozElementMixin(XULPopupElement); + MozElements.MozAutocompleteRichlistboxPopup = class MozAutocompleteRichlistboxPopup extends MozPopupElement { + constructor() { + super(); + + this.mInput = null; + this.mPopupOpen = false; + this._currentIndex = 0; + this._disabledItemClicked = false; + + this.setListeners(); + } + + initialize() { + this.setAttribute("ignorekeys", "true"); + this.setAttribute("level", "top"); + this.setAttribute("consumeoutsideclicks", "never"); + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + /** + * This is the default number of rows that we give the autocomplete + * popup when the textbox doesn't have a "maxrows" attribute + * for us to use. + */ + this.defaultMaxRows = 6; + + /** + * In some cases (e.g. when the input's dropmarker button is clicked), + * the input wants to display a popup with more rows. In that case, it + * should increase its maxRows property and store the "normal" maxRows + * in this field. When the popup is hidden, we restore the input's + * maxRows to the value stored in this field. + * + * This field is set to -1 between uses so that we can tell when it's + * been set by the input and when we need to set it in the popupshowing + * handler. + */ + this._normalMaxRows = -1; + this._previousSelectedIndex = -1; + this.mLastMoveTime = Date.now(); + this.mousedOverIndex = -1; + this._richlistbox = this.querySelector(".autocomplete-richlistbox"); + + if (!this.listEvents) { + this.listEvents = { + handleEvent: event => { + if (!this.parentNode) { + return; + } + + switch (event.type) { + case "mousedown": + this._disabledItemClicked = !!event.target.closest( + "richlistitem" + )?.disabled; + break; + case "mouseup": + // Don't call onPopupClick for the scrollbar buttons, thumb, + // slider, etc. If we hit the richlistbox and not a + // richlistitem, we ignore the event. + if ( + event.target.closest("richlistbox,richlistitem").localName == + "richlistitem" && + !this._disabledItemClicked + ) { + this.onPopupClick(event); + } + this._disabledItemClicked = false; + break; + case "mousemove": + if (Date.now() - this.mLastMoveTime <= 30) { + return; + } + + let item = event.target.closest("richlistbox,richlistitem"); + + // If we hit the richlistbox and not a richlistitem, we ignore + // the event. + if (item.localName == "richlistbox") { + return; + } + + let index = this.richlistbox.getIndexOfItem(item); + + this.mousedOverIndex = index; + + if (item.selectedByMouseOver) { + this.richlistbox.selectedIndex = index; + } + + this.mLastMoveTime = Date.now(); + break; + } + }, + }; + } + this.richlistbox.addEventListener("mousedown", this.listEvents); + this.richlistbox.addEventListener("mouseup", this.listEvents); + this.richlistbox.addEventListener("mousemove", this.listEvents); + } + + get richlistbox() { + if (!this._richlistbox) { + this.initialize(); + } + return this._richlistbox; + } + + static get markup() { + return ` + <richlistbox class="autocomplete-richlistbox" flex="1"/> + `; + } + + /** + * nsIAutoCompletePopup + */ + get input() { + return this.mInput; + } + + get overrideValue() { + return null; + } + + get popupOpen() { + return this.mPopupOpen; + } + + get maxRows() { + return (this.mInput && this.mInput.maxRows) || this.defaultMaxRows; + } + + set selectedIndex(val) { + if (val != this.richlistbox.selectedIndex) { + this._previousSelectedIndex = this.richlistbox.selectedIndex; + } + this.richlistbox.selectedIndex = val; + // Since ensureElementIsVisible may cause an expensive Layout flush, + // invoke it only if there may be a scrollbar, so if we could fetch + // more results than we can show at once. + // maxResults is the maximum number of fetched results, maxRows is the + // maximum number of rows we show at once, without a scrollbar. + if (this.mPopupOpen && this.maxResults > this.maxRows) { + // when clearing the selection (val == -1, so selectedItem will be + // null), we want to scroll back to the top. see bug #406194 + this.richlistbox.ensureElementIsVisible( + this.richlistbox.selectedItem || this.richlistbox.firstElementChild + ); + } + return val; + } + + get selectedIndex() { + return this.richlistbox.selectedIndex; + } + + get maxResults() { + // This is how many richlistitems will be kept around. + // Note, this getter may be overridden, or instances + // can have the nomaxresults attribute set to have no + // limit. + if (this.getAttribute("nomaxresults") == "true") { + return Infinity; + } + return 20; + } + + get matchCount() { + return Math.min(this.mInput.controller.matchCount, this.maxResults); + } + + get overflowPadding() { + return Number(this.getAttribute("overflowpadding")); + } + + set view(val) { + return val; + } + + get view() { + return this.mInput.controller; + } + + closePopup() { + if (this.mPopupOpen) { + this.hidePopup(); + this.removeAttribute("width"); + } + } + + getNextIndex(aReverse, aAmount, aIndex, aMaxRow) { + if (aMaxRow < 0) { + return -1; + } + + var newIdx = aIndex + (aReverse ? -1 : 1) * aAmount; + if ( + (aReverse && aIndex == -1) || + (newIdx > aMaxRow && aIndex != aMaxRow) + ) { + newIdx = aMaxRow; + } else if ((!aReverse && aIndex == -1) || (newIdx < 0 && aIndex != 0)) { + newIdx = 0; + } + + if ( + (newIdx < 0 && aIndex == 0) || + (newIdx > aMaxRow && aIndex == aMaxRow) + ) { + aIndex = -1; + } else { + aIndex = newIdx; + } + + return aIndex; + } + + onPopupClick(aEvent) { + this.input.controller.handleEnter(true, aEvent); + } + + onSearchBegin() { + this.mousedOverIndex = -1; + + if (typeof this._onSearchBegin == "function") { + this._onSearchBegin(); + } + } + + openAutocompletePopup(aInput, aElement) { + // until we have "baseBinding", (see bug #373652) this allows + // us to override openAutocompletePopup(), but still call + // the method on the base class + this._openAutocompletePopup(aInput, aElement); + } + + _openAutocompletePopup(aInput, aElement) { + if (!this._richlistbox) { + this.initialize(); + } + + if (!this.mPopupOpen) { + // It's possible that the panel is hidden initially + // to avoid impacting startup / new window performance + aInput.popup.hidden = false; + + this.mInput = aInput; + // clear any previous selection, see bugs 400671 and 488357 + this.selectedIndex = -1; + + var width = aElement.getBoundingClientRect().width; + this.setAttribute("width", width > 100 ? width : 100); + // invalidate() depends on the width attribute + this._invalidate(); + + this.openPopup(aElement, "after_start", 0, 0, false, false); + } + } + + invalidate(reason) { + // Don't bother doing work if we're not even showing + if (!this.mPopupOpen) { + return; + } + + this._invalidate(reason); + } + + _invalidate(reason) { + // collapsed if no matches + this.richlistbox.collapsed = this.matchCount == 0; + + // Update the richlistbox height. + if (this._adjustHeightRAFToken) { + cancelAnimationFrame(this._adjustHeightRAFToken); + this._adjustHeightRAFToken = null; + } + + if (this.mPopupOpen) { + delete this._adjustHeightOnPopupShown; + this._adjustHeightRAFToken = requestAnimationFrame(() => + this.adjustHeight() + ); + } else { + this._adjustHeightOnPopupShown = true; + } + + this._currentIndex = 0; + if (this._appendResultTimeout) { + clearTimeout(this._appendResultTimeout); + } + this._appendCurrentResult(reason); + } + + _collapseUnusedItems() { + let existingItemsCount = this.richlistbox.children.length; + for (let i = this.matchCount; i < existingItemsCount; ++i) { + let item = this.richlistbox.children[i]; + + item.collapsed = true; + if (typeof item._onCollapse == "function") { + item._onCollapse(); + } + } + } + + adjustHeight() { + // Figure out how many rows to show + let rows = this.richlistbox.children; + let numRows = Math.min(this.matchCount, this.maxRows, rows.length); + + // Default the height to 0 if we have no rows to show + let height = 0; + if (numRows) { + let firstRowRect = rows[0].getBoundingClientRect(); + if (this._rlbPadding == undefined) { + let style = window.getComputedStyle(this.richlistbox); + let paddingTop = parseInt(style.paddingTop) || 0; + let paddingBottom = parseInt(style.paddingBottom) || 0; + this._rlbPadding = paddingTop + paddingBottom; + } + + // The class `forceHandleUnderflow` is for the item might need to + // handle OverUnderflow or Overflow when the height of an item will + // be changed dynamically. + for (let i = 0; i < numRows; i++) { + if (rows[i].classList.contains("forceHandleUnderflow")) { + rows[i].handleOverUnderflow(); + } + } + + let lastRowRect = rows[numRows - 1].getBoundingClientRect(); + // Calculate the height to have the first row to last row shown + height = lastRowRect.bottom - firstRowRect.top + this._rlbPadding; + } + + this._collapseUnusedItems(); + + this.richlistbox.style.removeProperty("height"); + // We need to get the ceiling of the calculated value to ensure that the box fully contains + // all of its contents and doesn't cause a scrollbar since nsIBoxObject only expects a + // `long`. e.g. if `height` is 99.5 the richlistbox would render at height 99px with a + // scrollbar for the extra 0.5px. + this.richlistbox.height = Math.ceil(height); + } + + _appendCurrentResult(invalidateReason) { + var controller = this.mInput.controller; + var matchCount = this.matchCount; + var existingItemsCount = this.richlistbox.children.length; + + // Process maxRows per chunk to improve performance and user experience + for (let i = 0; i < this.maxRows; i++) { + if (this._currentIndex >= matchCount) { + break; + } + let item; + let itemExists = this._currentIndex < existingItemsCount; + + let originalValue, originalText, originalType; + let style = controller.getStyleAt(this._currentIndex); + let value = + style && style.includes("autofill") + ? controller.getFinalCompleteValueAt(this._currentIndex) + : controller.getValueAt(this._currentIndex); + let label = controller.getLabelAt(this._currentIndex); + let comment = controller.getCommentAt(this._currentIndex); + let image = controller.getImageAt(this._currentIndex); + // trim the leading/trailing whitespace + let trimmedSearchString = controller.searchString + .replace(/^\s+/, "") + .replace(/\s+$/, ""); + + let reusable = false; + if (itemExists) { + item = this.richlistbox.children[this._currentIndex]; + + // Url may be a modified version of value, see _adjustAcItem(). + originalValue = + item.getAttribute("url") || item.getAttribute("ac-value"); + originalText = item.getAttribute("ac-text"); + originalType = item.getAttribute("originaltype"); + + // The styles on the list which have different <content> structure and overrided + // _adjustAcItem() are unreusable. + const UNREUSEABLE_STYLES = [ + "autofill-profile", + "autofill-footer", + "autofill-clear-button", + "autofill-insecureWarning", + "generatedPassword", + "importableLearnMore", + "importableLogins", + "insecureWarning", + "loginsFooter", + "loginWithOrigin", + ]; + // Reuse the item when its style is exactly equal to the previous style or + // neither of their style are in the UNREUSEABLE_STYLES. + reusable = + originalType === style || + !( + UNREUSEABLE_STYLES.includes(style) || + UNREUSEABLE_STYLES.includes(originalType) + ); + } + + // If no reusable item available, then create a new item. + if (!reusable) { + let options = null; + switch (style) { + case "autofill-profile": + options = { is: "autocomplete-profile-listitem" }; + break; + case "autofill-footer": + options = { is: "autocomplete-profile-listitem-footer" }; + break; + case "autofill-clear-button": + options = { is: "autocomplete-profile-listitem-clear-button" }; + break; + case "autofill-insecureWarning": + options = { is: "autocomplete-creditcard-insecure-field" }; + break; + case "importableLearnMore": + options = { + is: "autocomplete-importable-learn-more-richlistitem", + }; + break; + case "importableLogins": + options = { is: "autocomplete-importable-logins-richlistitem" }; + break; + case "generatedPassword": + options = { is: "autocomplete-generated-password-richlistitem" }; + break; + case "insecureWarning": + options = { is: "autocomplete-richlistitem-insecure-warning" }; + break; + case "loginsFooter": + options = { is: "autocomplete-richlistitem-logins-footer" }; + break; + case "loginWithOrigin": + options = { is: "autocomplete-login-richlistitem" }; + break; + default: + options = { is: "autocomplete-richlistitem" }; + } + item = document.createXULElement("richlistitem", options); + item.className = "autocomplete-richlistitem"; + } + + item.setAttribute("dir", this.style.direction); + item.setAttribute("ac-image", image); + item.setAttribute("ac-value", value); + item.setAttribute("ac-label", label); + item.setAttribute("ac-comment", comment); + item.setAttribute("ac-text", trimmedSearchString); + + // Completely reuse the existing richlistitem for invalidation + // due to new results, but only when: the item is the same, *OR* + // we are about to replace the currently moused-over item, to + // avoid surprising the user. + let iface = Ci.nsIAutoCompletePopup; + if ( + reusable && + originalText == trimmedSearchString && + invalidateReason == iface.INVALIDATE_REASON_NEW_RESULT && + (originalValue == value || + this.mousedOverIndex === this._currentIndex) + ) { + // try to re-use the existing item + item._reuseAcItem(); + this._currentIndex++; + continue; + } else { + if (typeof item._cleanup == "function") { + item._cleanup(); + } + item.setAttribute("originaltype", style); + } + + if (reusable) { + // Adjust only when the result's type is reusable for existing + // item's. Otherwise, we might insensibly call old _adjustAcItem() + // as new binding has not been attached yet. + // We don't need to worry about switching to new binding, since + // _adjustAcItem() will fired by its own constructor accordingly. + item._adjustAcItem(); + item.collapsed = false; + } else if (itemExists) { + let oldItem = this.richlistbox.children[this._currentIndex]; + this.richlistbox.replaceChild(item, oldItem); + } else { + this.richlistbox.appendChild(item); + } + + this._currentIndex++; + } + + if (typeof this.onResultsAdded == "function") { + // The items bindings may not be attached yet, so we must delay this + // before we can properly handle items properly without breaking + // the richlistbox. + Services.tm.dispatchToMainThread(() => this.onResultsAdded()); + } + + if (this._currentIndex < matchCount) { + // yield after each batch of items so that typing the url bar is + // responsive + this._appendResultTimeout = setTimeout( + () => this._appendCurrentResult(), + 0 + ); + } + } + + selectBy(aReverse, aPage) { + try { + var amount = aPage ? 5 : 1; + + // because we collapsed unused items, we can't use this.richlistbox.getRowCount(), we need to use the matchCount + this.selectedIndex = this.getNextIndex( + aReverse, + amount, + this.selectedIndex, + this.matchCount - 1 + ); + if (this.selectedIndex == -1) { + this.input._focus(); + } + } catch (ex) { + // do nothing - occasionally timer-related js errors happen here + // e.g. "this.selectedIndex has no properties", when you type fast and hit a + // navigation key before this popup has opened + } + } + + disconnectedCallback() { + if (this.listEvents) { + this.richlistbox.removeEventListener("mousedown", this.listEvents); + this.richlistbox.removeEventListener("mouseup", this.listEvents); + this.richlistbox.removeEventListener("mousemove", this.listEvents); + delete this.listEvents; + } + } + + setListeners() { + this.addEventListener("popupshowing", event => { + // If normalMaxRows wasn't already set by the input, then set it here + // so that we restore the correct number when the popup is hidden. + + // Null-check this.mInput; see bug 1017914 + if (this._normalMaxRows < 0 && this.mInput) { + this._normalMaxRows = this.mInput.maxRows; + } + + this.mPopupOpen = true; + }); + + this.addEventListener("popupshown", event => { + if (this._adjustHeightOnPopupShown) { + delete this._adjustHeightOnPopupShown; + this.adjustHeight(); + } + }); + + this.addEventListener("popuphiding", event => { + var isListActive = true; + if (this.selectedIndex == -1) { + isListActive = false; + } + this.input.controller.stopSearch(); + + this.mPopupOpen = false; + + // Reset the maxRows property to the cached "normal" value (if there's + // any), and reset normalMaxRows so that we can detect whether it was set + // by the input when the popupshowing handler runs. + + // Null-check this.mInput; see bug 1017914 + if (this.mInput && this._normalMaxRows > 0) { + this.mInput.maxRows = this._normalMaxRows; + } + this._normalMaxRows = -1; + // If the list was being navigated and then closed, make sure + // we fire accessible focus event back to textbox + + // Null-check this.mInput; see bug 1017914 + if (isListActive && this.mInput) { + this.mInput.mIgnoreFocus = true; + this.mInput._focus(); + this.mInput.mIgnoreFocus = false; + } + }); + } + }; + + MozPopupElement.implementCustomInterface( + MozElements.MozAutocompleteRichlistboxPopup, + [Ci.nsIAutoCompletePopup] + ); + + customElements.define( + "autocomplete-richlistbox-popup", + MozElements.MozAutocompleteRichlistboxPopup, + { + extends: "panel", + } + ); +} diff --git a/toolkit/content/widgets/autocomplete-richlistitem.js b/toolkit/content/widgets/autocomplete-richlistitem.js new file mode 100644 index 0000000000..8fc0c12363 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-richlistitem.js @@ -0,0 +1,884 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + MozElements.MozAutocompleteRichlistitem = class MozAutocompleteRichlistitem extends MozElements.MozRichlistitem { + constructor() { + super(); + + /** + * This overrides listitem's mousedown handler because we want to set the + * selected item even when the shift or accel keys are pressed. + */ + this.addEventListener("mousedown", event => { + // Call this.control only once since it's not a simple getter. + let control = this.control; + if (!control || control.disabled) { + return; + } + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + }); + + this.addEventListener("mouseover", event => { + // The point of implementing this handler is to allow drags to change + // the selected item. If the user mouses down on an item, it becomes + // selected. If they then drag the mouse to another item, select it. + // Handle all three primary mouse buttons: right, left, and wheel, since + // all three change the selection on mousedown. + let mouseDown = event.buttons & 0b111; + if (!mouseDown) { + return; + } + // Call this.control only once since it's not a simple getter. + let control = this.control; + if (!control || control.disabled) { + return; + } + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + }); + + this.addEventListener("overflow", () => this._onOverflow()); + this.addEventListener("underflow", () => this._onUnderflow()); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + + this._boundaryCutoff = null; + this._inOverflow = false; + + this._adjustAcItem(); + } + + static get inheritedAttributes() { + return { + ".ac-type-icon": "selected,current,type", + ".ac-site-icon": "src=image,selected,type", + ".ac-title": "selected", + ".ac-title-text": "selected", + ".ac-separator": "selected,type", + ".ac-url": "selected", + ".ac-url-text": "selected", + }; + } + + static get markup() { + return ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <hbox class="ac-title" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text"/> + </description> + </hbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center" aria-hidden="true"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + get _typeIcon() { + return this.querySelector(".ac-type-icon"); + } + + get _titleText() { + return this.querySelector(".ac-title-text"); + } + + get _separator() { + return this.querySelector(".ac-separator"); + } + + get _urlText() { + return this.querySelector(".ac-url-text"); + } + + get _stringBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://global/locale/autocomplete.properties" + ); + } + return this.__stringBundle; + } + + get boundaryCutoff() { + if (!this._boundaryCutoff) { + this._boundaryCutoff = Services.prefs.getIntPref( + "toolkit.autocomplete.richBoundaryCutoff" + ); + } + return this._boundaryCutoff; + } + + _cleanup() { + this.removeAttribute("url"); + this.removeAttribute("image"); + this.removeAttribute("title"); + this.removeAttribute("text"); + } + + _onOverflow() { + this._inOverflow = true; + this._handleOverflow(); + } + + _onUnderflow() { + this._inOverflow = false; + this._handleOverflow(); + } + + _getBoundaryIndices(aText, aSearchTokens) { + // Short circuit for empty search ([""] == "") + if (aSearchTokens == "") { + return [0, aText.length]; + } + + // Find which regions of text match the search terms + let regions = []; + for (let search of Array.prototype.slice.call(aSearchTokens)) { + let matchIndex = -1; + let searchLen = search.length; + + // Find all matches of the search terms, but stop early for perf + let lowerText = aText.substr(0, this.boundaryCutoff).toLowerCase(); + while ((matchIndex = lowerText.indexOf(search, matchIndex + 1)) >= 0) { + regions.push([matchIndex, matchIndex + searchLen]); + } + } + + // Sort the regions by start position then end position + regions = regions.sort((a, b) => { + let start = a[0] - b[0]; + return start == 0 ? a[1] - b[1] : start; + }); + + // Generate the boundary indices from each region + let start = 0; + let end = 0; + let boundaries = []; + let len = regions.length; + for (let i = 0; i < len; i++) { + // We have a new boundary if the start of the next is past the end + let region = regions[i]; + if (region[0] > end) { + // First index is the beginning of match + boundaries.push(start); + // Second index is the beginning of non-match + boundaries.push(end); + + // Track the new region now that we've stored the previous one + start = region[0]; + } + + // Push back the end index for the current or new region + end = Math.max(end, region[1]); + } + + // Add the last region + boundaries.push(start); + boundaries.push(end); + + // Put on the end boundary if necessary + if (end < aText.length) { + boundaries.push(aText.length); + } + + // Skip the first item because it's always 0 + return boundaries.slice(1); + } + + _getSearchTokens(aSearch) { + let search = aSearch.toLowerCase(); + return search.split(/\s+/); + } + + _setUpDescription(aDescriptionElement, aText) { + // Get rid of all previous text + if (!aDescriptionElement) { + return; + } + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + // Get the indices that separate match and non-match text + let search = this.getAttribute("text"); + let tokens = this._getSearchTokens(search); + let indices = this._getBoundaryIndices(aText, tokens); + + this._appendDescriptionSpans( + indices, + aText, + aDescriptionElement, + aDescriptionElement + ); + } + + _appendDescriptionSpans( + indices, + text, + spansParentElement, + descriptionElement + ) { + let next; + let start = 0; + let len = indices.length; + // Even indexed boundaries are matches, so skip the 0th if it's empty + for (let i = indices[0] == 0 ? 1 : 0; i < len; i++) { + next = indices[i]; + let spanText = text.substr(start, next - start); + start = next; + + if (i % 2 == 0) { + // Emphasize the text for even indices + let span = spansParentElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + this._setUpEmphasisSpan(span, descriptionElement); + span.textContent = spanText; + } else { + // Otherwise, it's plain text + spansParentElement.appendChild(document.createTextNode(spanText)); + } + } + } + + _setUpEmphasisSpan(aSpan, aDescriptionElement) { + aSpan.classList.add("ac-emphasize-text"); + switch (aDescriptionElement) { + case this._titleText: + aSpan.classList.add("ac-emphasize-text-title"); + break; + case this._urlText: + aSpan.classList.add("ac-emphasize-text-url"); + break; + } + } + + /** + * This will generate an array of emphasis pairs for use with + * _setUpEmphasisedSections(). Each pair is a tuple (array) that + * represents a block of text - containing the text of that block, and a + * boolean for whether that block should have an emphasis styling applied + * to it. + * + * These pairs are generated by parsing a localised string (aSourceString) + * with parameters, in the format that is used by + * nsIStringBundle.formatStringFromName(): + * + * "textA %1$S textB textC %2$S" + * + * Or: + * + * "textA %S" + * + * Where "%1$S", "%2$S", and "%S" are intended to be replaced by provided + * replacement strings. These are specified an array of tuples + * (aReplacements), each containing the replacement text and a boolean for + * whether that text should have an emphasis styling applied. This is used + * as a 1-based array - ie, "%1$S" is replaced by the item in the first + * index of aReplacements, "%2$S" by the second, etc. "%S" will always + * match the first index. + */ + _generateEmphasisPairs(aSourceString, aReplacements) { + let pairs = []; + + // Split on %S, %1$S, %2$S, etc. ie: + // "textA %S" + // becomes ["textA ", "%S"] + // "textA %1$S textB textC %2$S" + // becomes ["textA ", "%1$S", " textB textC ", "%2$S"] + let parts = aSourceString.split(/(%(?:[0-9]+\$)?S)/); + + for (let part of parts) { + // The above regex will actually give us an empty string at the + // end - we don't want that, as we don't want to later generate an + // empty text node for it. + if (part.length === 0) { + continue; + } + + // Determine if this token is a replacement token or a normal text + // token. If it is a replacement token, we want to extract the + // numerical number. However, we still want to match on "$S". + let match = part.match(/^%(?:([0-9]+)\$)?S$/); + + if (match) { + // "%S" doesn't have a numerical number in it, but will always + // be assumed to be 1. Furthermore, the input string specifies + // these with a 1-based index, but we want a 0-based index. + let index = (match[1] || 1) - 1; + + if (index >= 0 && index < aReplacements.length) { + pairs.push([...aReplacements[index]]); + } + } else { + pairs.push([part]); + } + } + + return pairs; + } + + /** + * _setUpEmphasisedSections() has the same use as _setUpDescription, + * except instead of taking a string and highlighting given tokens, it takes + * an array of pairs generated by _generateEmphasisPairs(). This allows + * control over emphasising based on specific blocks of text, rather than + * search for substrings. + */ + _setUpEmphasisedSections(aDescriptionElement, aTextPairs) { + // Get rid of all previous text + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + for (let [text, emphasise] of aTextPairs) { + if (emphasise) { + let span = aDescriptionElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + span.textContent = text; + switch (emphasise) { + case "match": + this._setUpEmphasisSpan(span, aDescriptionElement); + break; + } + } else { + aDescriptionElement.appendChild(document.createTextNode(text)); + } + } + } + + _unescapeUrl(url) { + return Services.textToSubURI.unEscapeURIForUI(url); + } + + _reuseAcItem() { + this.collapsed = false; + + // The popup may have changed size between now and the last + // time the item was shown, so always handle over/underflow. + let dwu = window.windowUtils; + let popupWidth = dwu.getBoundsWithoutFlushing(this.parentNode).width; + if (!this._previousPopupWidth || this._previousPopupWidth != popupWidth) { + this._previousPopupWidth = popupWidth; + this.handleOverUnderflow(); + } + } + + _adjustAcItem() { + let originalUrl = this.getAttribute("ac-value"); + let title = this.getAttribute("ac-comment"); + this.setAttribute("url", originalUrl); + this.setAttribute("image", this.getAttribute("ac-image")); + this.setAttribute("title", title); + this.setAttribute("text", this.getAttribute("ac-text")); + + let type = this.getAttribute("originaltype"); + let types = new Set(type.split(/\s+/)); + // Remove types that should ultimately not be in the `type` string. + types.delete("autofill"); + type = [...types][0] || ""; + this.setAttribute("type", type); + + let displayUrl = this._unescapeUrl(originalUrl); + + // Show the domain as the title if we don't have a title. + if (!title) { + try { + let uri = Services.io.newURI(originalUrl); + // Not all valid URLs have a domain. + if (uri.host) { + title = uri.host; + } + } catch (e) {} + if (!title) { + title = displayUrl; + } + } + + if (Array.isArray(title)) { + this._setUpEmphasisedSections(this._titleText, title); + } else { + this._setUpDescription(this._titleText, title); + } + this._setUpDescription(this._urlText, displayUrl); + + // Removing the max-width may be jarring when the item is visible, but + // we have no other choice to properly crop the text. + // Removing max-widths may cause overflow or underflow events, that + // will set the _inOverflow property. In case both the old and the new + // text are overflowing, the overflow event won't happen, and we must + // enforce an _handleOverflow() call to update the max-widths. + let wasInOverflow = this._inOverflow; + this._removeMaxWidths(); + if (wasInOverflow && this._inOverflow) { + this._handleOverflow(); + } + } + + _removeMaxWidths() { + if (this._hasMaxWidths) { + this._titleText.style.removeProperty("max-width"); + this._urlText.style.removeProperty("max-width"); + this._hasMaxWidths = false; + } + } + + /** + * This method truncates the displayed strings as necessary. + */ + _handleOverflow() { + let itemRect = this.parentNode.getBoundingClientRect(); + let titleRect = this._titleText.getBoundingClientRect(); + let separatorRect = this._separator.getBoundingClientRect(); + let urlRect = this._urlText.getBoundingClientRect(); + let separatorURLWidth = separatorRect.width + urlRect.width; + + // Total width for the title and URL is the width of the item + // minus the start of the title text minus a little optional extra padding. + // This extra padding amount is basically arbitrary but keeps the text + // from getting too close to the popup's edge. + let dir = this.getAttribute("dir"); + let titleStart = + dir == "rtl" + ? itemRect.right - titleRect.right + : titleRect.left - itemRect.left; + + let popup = this.parentNode.parentNode; + let itemWidth = + itemRect.width - + titleStart - + popup.overflowPadding - + (popup.margins ? popup.margins.end : 0); + + let titleWidth = titleRect.width; + if (titleWidth + separatorURLWidth > itemWidth) { + // The percentage of the item width allocated to the title. + let titlePct = 0.66; + + let titleAvailable = itemWidth - separatorURLWidth; + let titleMaxWidth = Math.max(titleAvailable, itemWidth * titlePct); + if (titleWidth > titleMaxWidth) { + this._titleText.style.maxWidth = titleMaxWidth + "px"; + } + let urlMaxWidth = Math.max( + itemWidth - titleWidth, + itemWidth * (1 - titlePct) + ); + urlMaxWidth -= separatorRect.width; + this._urlText.style.maxWidth = urlMaxWidth + "px"; + this._hasMaxWidths = true; + } + } + + handleOverUnderflow() { + this._removeMaxWidths(); + this._handleOverflow(); + } + }; + + MozXULElement.implementCustomInterface( + MozElements.MozAutocompleteRichlistitem, + [Ci.nsIDOMXULSelectControlItemElement] + ); + + class MozAutocompleteRichlistitemInsecureWarning extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + let baseURL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" + ); + window.openTrustedLinkIn(baseURL + "insecure-password", "tab", { + relatedToCurrent: true, + }); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + super.connectedCallback(); + + // Unlike other autocomplete items, the height of the insecure warning + // increases by wrapping. So "forceHandleUnderflow" is for container to + // recalculate an item's height and width. + this.classList.add("forceHandleUnderflow"); + } + + static get inheritedAttributes() { + return { + ".ac-type-icon": "selected,current,type", + ".ac-site-icon": "src=image,selected,type", + ".ac-title-text": "selected", + ".ac-separator": "selected,type", + ".ac-url": "selected", + ".ac-url-text": "selected", + }; + } + + static get markup() { + return ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <vbox class="ac-title" align="left"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text"/> + </description> + </vbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + get _learnMoreString() { + if (!this.__learnMoreString) { + this.__learnMoreString = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .GetStringFromName("insecureFieldWarningLearnMore"); + } + return this.__learnMoreString; + } + + /** + * Override _getSearchTokens to have the Learn More text emphasized + */ + _getSearchTokens(aSearch) { + return [this._learnMoreString.toLowerCase()]; + } + } + + class MozAutocompleteRichlistitemLoginsFooter extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + + function handleEvent(event) { + if (event.button != 0) { + return; + } + + const { LoginHelper } = ChromeUtils.import( + "resource://gre/modules/LoginHelper.jsm" + ); + + LoginHelper.openPasswordManager(this.ownerGlobal, { + entryPoint: "autocomplete", + }); + } + + this.addEventListener("click", handleEvent); + } + } + + class MozAutocompleteImportableLearnMoreRichlistitem extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + + this.addEventListener("click", event => { + window.openTrustedLinkIn( + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-import", + "tab", + { relatedToCurrent: true } + ); + }); + } + + static get markup() { + return ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <vbox class="ac-title" align="left"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text" + data-l10n-id="autocomplete-import-learn-more"/> + </description> + </vbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + // Override to avoid clearing out fluent description. + _setUpDescription() {} + } + + class MozAutocompleteTwoLineRichlistitem extends MozElements.MozRichlistitem { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + this._adjustAcItem(); + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // getCommentAt: + ".line2-label": "text=ac-label", + }; + } + + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="two-line-wrapper"> + <xul:image class="ac-site-icon"></xul:image> + <div class="labels-wrapper"> + <div class="label-row line1-label"></div> + <div class="label-row line2-label"></div> + </div> + </div> + `; + } + + _adjustAcItem() { + const popup = this.parentNode.parentNode; + const minWidth = getComputedStyle(popup).minWidth.replace("px", ""); + // Make item fit in popup as XUL box could not constrain + // item's width + // popup.width is equal to the input field's width from the content process + this.firstElementChild.style.width = + Math.max(minWidth, popup.width) + "px"; + } + + _onOverflow() {} + + _onUnderflow() {} + + handleOverUnderflow() {} + } + + class MozAutocompleteLoginRichlistitem extends MozAutocompleteTwoLineRichlistitem { + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // Don't inherit ac-label with getCommentAt since the label is JSON. + }; + } + + _adjustAcItem() { + super._adjustAcItem(); + + let details = JSON.parse(this.getAttribute("ac-label")); + this.querySelector(".line2-label").textContent = details.comment; + } + } + + class MozAutocompleteGeneratedPasswordRichlistitem extends MozAutocompleteTwoLineRichlistitem { + constructor() { + super(); + + // Line 2 and line 3 both display text with a different line-height than + // line 1 but we want the line-height to be the same so we wrap the text + // in <span> and only adjust the line-height via font CSS properties on them. + this.generatedPasswordText = document.createElement("span"); + + this.line3Text = document.createElement("span"); + this.line3 = document.createElement("div"); + this.line3.className = "label-row generated-password-autosave"; + this.line3.append(this.line3Text); + } + + get _autoSaveString() { + if (!this.__autoSaveString) { + let brandShorterName = Services.strings + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShorterName"); + this.__autoSaveString = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .formatStringFromName("generatedPasswordWillBeSaved", [ + brandShorterName, + ]); + } + return this.__autoSaveString; + } + + _adjustAcItem() { + let { generatedPassword, willAutoSaveGeneratedPassword } = JSON.parse( + this.getAttribute("ac-label") + ); + let line2Label = this.querySelector(".line2-label"); + line2Label.textContent = ""; + this.generatedPasswordText.textContent = generatedPassword; + line2Label.append(this.generatedPasswordText); + + if (willAutoSaveGeneratedPassword) { + this.line3Text.textContent = this._autoSaveString; + this.querySelector(".labels-wrapper").append(this.line3); + } else { + this.line3.remove(); + } + + super._adjustAcItem(); + } + } + + class MozAutocompleteImportableLoginsRichlistitem extends MozAutocompleteTwoLineRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + // Let the login manager parent handle this importable browser click. + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal + .getActor("LoginManager") + .receiveMessage({ + name: "PasswordManager:HandleImportable", + data: { + browserId: this.getAttribute("ac-value"), + }, + }); + }); + } + + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="two-line-wrapper"> + <xul:image class="ac-site-icon" /> + <div class="labels-wrapper"> + <div class="label-row line1-label" data-l10n-name="line1" /> + <div class="label-row line2-label" data-l10n-name="line2" /> + </div> + </div> + `; + } + + _adjustAcItem() { + document.l10n.setAttributes( + this.querySelector(".labels-wrapper"), + `autocomplete-import-logins-${this.getAttribute("ac-value")}`, + { + host: this.getAttribute("ac-label").replace(/^www\./, ""), + } + ); + super._adjustAcItem(); + } + } + + customElements.define( + "autocomplete-richlistitem", + MozElements.MozAutocompleteRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-richlistitem-insecure-warning", + MozAutocompleteRichlistitemInsecureWarning, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-richlistitem-logins-footer", + MozAutocompleteRichlistitemLoginsFooter, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-two-line-richlistitem", + MozAutocompleteTwoLineRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-login-richlistitem", + MozAutocompleteLoginRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-generated-password-richlistitem", + MozAutocompleteGeneratedPasswordRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-importable-learn-more-richlistitem", + MozAutocompleteImportableLearnMoreRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-importable-logins-richlistitem", + MozAutocompleteImportableLoginsRichlistitem, + { + extends: "richlistitem", + } + ); +} diff --git a/toolkit/content/widgets/browser-custom-element.js b/toolkit/content/widgets/browser-custom-element.js new file mode 100644 index 0000000000..e7a2c7e642 --- /dev/null +++ b/toolkit/content/widgets/browser-custom-element.js @@ -0,0 +1,1898 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + const { BrowserUtils } = ChromeUtils.import( + "resource://gre/modules/BrowserUtils.jsm" + ); + + const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" + ); + + let LazyModules = {}; + + ChromeUtils.defineModuleGetter( + LazyModules, + "E10SUtils", + "resource://gre/modules/E10SUtils.jsm" + ); + + ChromeUtils.defineModuleGetter( + LazyModules, + "RemoteWebNavigation", + "resource://gre/modules/RemoteWebNavigation.jsm" + ); + + ChromeUtils.defineModuleGetter( + LazyModules, + "PopupBlocker", + "resource://gre/actors/PopupBlockingParent.jsm" + ); + + let lazyPrefs = {}; + XPCOMUtils.defineLazyPreferenceGetter( + lazyPrefs, + "unloadTimeoutMs", + "dom.beforeunload_timeout_ms" + ); + + const elementsToDestroyOnUnload = new Set(); + + window.addEventListener( + "unload", + () => { + for (let element of elementsToDestroyOnUnload.values()) { + element.destroy(); + } + elementsToDestroyOnUnload.clear(); + }, + { mozSystemGroup: true, once: true } + ); + + class MozBrowser extends MozElements.MozElementMixin(XULFrameElement) { + static get observedAttributes() { + return ["remote"]; + } + + constructor() { + super(); + + this.onPageHide = this.onPageHide.bind(this); + + this.isNavigating = false; + + this._documentURI = null; + this._characterSet = null; + this._documentContentType = null; + + this._inPermitUnload = new WeakSet(); + + /** + * These are managed by the tabbrowser: + */ + this.droppedLinkHandler = null; + this.mIconURL = null; + this.lastURI = null; + + XPCOMUtils.defineLazyGetter(this, "popupBlocker", () => { + return new LazyModules.PopupBlocker(this); + }); + + this.addEventListener( + "dragover", + event => { + if (!this.droppedLinkHandler || event.defaultPrevented) { + return; + } + + // For drags that appear to be internal text (for example, tab drags), + // set the dropEffect to 'none'. This prevents the drop even if some + // other listener cancelled the event. + var types = event.dataTransfer.types; + if ( + types.includes("text/x-moz-text-internal") && + !types.includes("text/plain") + ) { + event.dataTransfer.dropEffect = "none"; + event.stopPropagation(); + event.preventDefault(); + } + + // No need to handle "dragover" in e10s, since nsDocShellTreeOwner.cpp in the child process + // handles that case using "@mozilla.org/content/dropped-link-handler;1" service. + if (this.isRemoteBrowser) { + return; + } + + let linkHandler = Services.droppedLinkHandler; + if (linkHandler.canDropLink(event, false)) { + event.preventDefault(); + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener( + "drop", + event => { + // No need to handle "drop" in e10s, since nsDocShellTreeOwner.cpp in the child process + // handles that case using "@mozilla.org/content/dropped-link-handler;1" service. + if ( + !this.droppedLinkHandler || + event.defaultPrevented || + this.isRemoteBrowser + ) { + return; + } + + let linkHandler = Services.droppedLinkHandler; + try { + // Pass true to prevent the dropping of javascript:/data: URIs + var links = linkHandler.dropLinks(event, true); + } catch (ex) { + return; + } + + if (links.length) { + let triggeringPrincipal = linkHandler.getTriggeringPrincipal(event); + this.droppedLinkHandler(event, links, triggeringPrincipal); + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener("dragstart", event => { + // If we're a remote browser dealing with a dragstart, stop it + // from propagating up, since our content process should be dealing + // with the mouse movement. + if (this.isRemoteBrowser) { + event.stopPropagation(); + } + }); + } + + resetFields() { + if (this.observer) { + try { + Services.obs.removeObserver( + this.observer, + "browser:purge-session-history" + ); + } catch (ex) { + // It's not clear why this sometimes throws an exception. + } + this.observer = null; + } + + let browser = this; + this.observer = { + observe(aSubject, aTopic, aState) { + if (aTopic == "browser:purge-session-history") { + browser.purgeSessionHistory(); + } else if (aTopic == "apz:cancel-autoscroll") { + if (aState == browser._autoScrollScrollId) { + // Set this._autoScrollScrollId to null, so in stopScroll() we + // don't call stopApzAutoscroll() (since it's APZ that + // initiated the stopping). + browser._autoScrollScrollId = null; + browser._autoScrollPresShellId = null; + + browser._autoScrollPopup.hidePopup(); + } + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + + this._documentURI = null; + + this._documentContentType = null; + + this._loadContext = null; + + this._webBrowserFind = null; + + this._finder = null; + + this._remoteFinder = null; + + this._fastFind = null; + + this._lastSearchString = null; + + this._characterSet = ""; + + this._mayEnableCharacterEncodingMenu = null; + + this._charsetAutodetected = false; + + this._contentPrincipal = null; + + this._contentPartitionedPrincipal = null; + + this._csp = null; + + this._referrerInfo = null; + + this._contentRequestContextID = null; + + this._rdmFullZoom = 1.0; + + this._isSyntheticDocument = false; + + this.mPrefs = Services.prefs; + + this._mStrBundle = null; + + this._audioMuted = false; + + this._hasAnyPlayingMediaBeenBlocked = false; + + this._unselectedTabHoverMessageListenerCount = 0; + + this.urlbarChangeTracker = { + _startedLoadSinceLastUserTyping: false, + + startedLoad() { + this._startedLoadSinceLastUserTyping = true; + }, + finishedLoad() { + this._startedLoadSinceLastUserTyping = false; + }, + userTyped() { + this._startedLoadSinceLastUserTyping = false; + }, + }; + + this._userTypedValue = null; + + this._AUTOSCROLL_SNAP = 10; + + this._autoScrollBrowsingContext = null; + + this._startX = null; + + this._startY = null; + + this._autoScrollPopup = null; + + /** + * These IDs identify the scroll frame being autoscrolled. + */ + this._autoScrollScrollId = null; + + this._autoScrollPresShellId = null; + } + + connectedCallback() { + // We typically use this to avoid running JS that triggers a layout during parse + // (see comment on the delayConnectedCallback implementation). In this case, we + // are using it to avoid a leak - see https://bugzilla.mozilla.org/show_bug.cgi?id=1441935#c20. + if (this.delayConnectedCallback()) { + return; + } + + this.construct(); + } + + disconnectedCallback() { + this.destroy(); + } + + get autoscrollEnabled() { + if (this.getAttribute("autoscroll") == "false") { + return false; + } + + return this.mPrefs.getBoolPref("general.autoScroll", true); + } + + get canGoBack() { + return this.webNavigation.canGoBack; + } + + get canGoForward() { + return this.webNavigation.canGoForward; + } + + get currentURI() { + if (this.webNavigation) { + return this.webNavigation.currentURI; + } + return null; + } + + get documentURI() { + return this.isRemoteBrowser + ? this._documentURI + : this.contentDocument.documentURIObject; + } + + get documentContentType() { + if (this.isRemoteBrowser) { + return this._documentContentType; + } + return this.contentDocument ? this.contentDocument.contentType : null; + } + + set documentContentType(aContentType) { + if (aContentType != null) { + if (this.isRemoteBrowser) { + this._documentContentType = aContentType; + } else { + this.contentDocument.documentContentType = aContentType; + } + } + } + + get loadContext() { + if (this._loadContext) { + return this._loadContext; + } + + let { frameLoader } = this; + if (!frameLoader) { + return null; + } + this._loadContext = frameLoader.loadContext; + return this._loadContext; + } + + get autoCompletePopup() { + return document.getElementById(this.getAttribute("autocompletepopup")); + } + + get dateTimePicker() { + return document.getElementById(this.getAttribute("datetimepicker")); + } + + /** + * Provides a node to hang popups (such as the datetimepicker) from. + * If this <browser> isn't the descendant of a <stack>, null is returned + * instead and popup code must handle this case. + */ + get popupAnchor() { + let stack = this.closest("stack"); + if (!stack) { + return null; + } + + let popupAnchor = stack.querySelector(".popup-anchor"); + if (popupAnchor) { + return popupAnchor; + } + + // Create an anchor for the popup + popupAnchor = document.createElement("div"); + popupAnchor.className = "popup-anchor"; + popupAnchor.hidden = true; + stack.appendChild(popupAnchor); + return popupAnchor; + } + + set suspendMediaWhenInactive(val) { + this.browsingContext.suspendMediaWhenInactive = val; + } + + get suspendMediaWhenInactive() { + return !!this.browsingContext?.suspendMediaWhenInactive; + } + + set docShellIsActive(val) { + this.browsingContext.isActive = val; + if (this.isRemoteBrowser) { + let remoteTab = this.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.renderLayers = val; + } + } + } + + get docShellIsActive() { + return !!this.browsingContext?.isActive; + } + + set renderLayers(val) { + if (this.isRemoteBrowser) { + let remoteTab = this.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.renderLayers = val; + } + } else { + this.docShellIsActive = val; + } + } + + get renderLayers() { + if (this.isRemoteBrowser) { + return !!this.frameLoader?.remoteTab?.renderLayers; + } + return this.docShellIsActive; + } + + get hasLayers() { + if (this.isRemoteBrowser) { + return !!this.frameLoader?.remoteTab?.hasLayers; + } + return this.docShellIsActive; + } + + get isRemoteBrowser() { + return this.getAttribute("remote") == "true"; + } + + get remoteType() { + return this.browsingContext?.currentRemoteType; + } + + get isCrashed() { + if (!this.isRemoteBrowser || !this.frameLoader) { + return false; + } + + return !this.frameLoader.remoteTab; + } + + get messageManager() { + // Bug 1524084 - Trying to get at the message manager while in the crashed state will + // create a new message manager that won't shut down properly when the crashed browser + // is removed from the DOM. We work around that right now by returning null if we're + // in the crashed state. + if (this.frameLoader && !this.isCrashed) { + return this.frameLoader.messageManager; + } + return null; + } + + get webBrowserFind() { + if (!this._webBrowserFind) { + this._webBrowserFind = this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + return this._webBrowserFind; + } + + get finder() { + if (this.isRemoteBrowser) { + if (!this._remoteFinder) { + let jsm = "resource://gre/modules/FinderParent.jsm"; + let { FinderParent } = ChromeUtils.import(jsm, {}); + this._remoteFinder = new FinderParent(this); + } + return this._remoteFinder; + } + if (!this._finder) { + if (!this.docShell) { + return null; + } + + let Finder = ChromeUtils.import("resource://gre/modules/Finder.jsm", {}) + .Finder; + this._finder = new Finder(this.docShell); + } + return this._finder; + } + + get fastFind() { + if (!this._fastFind) { + if (!("@mozilla.org/typeaheadfind;1" in Cc)) { + return null; + } + + var tabBrowser = this.getTabBrowser(); + if (tabBrowser && "fastFind" in tabBrowser) { + return (this._fastFind = tabBrowser.fastFind); + } + + if (!this.docShell) { + return null; + } + + this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance( + Ci.nsITypeAheadFind + ); + this._fastFind.init(this.docShell); + } + return this._fastFind; + } + + get outerWindowID() { + return this.browsingContext?.currentWindowGlobal?.outerWindowId; + } + + get innerWindowID() { + return this.browsingContext?.currentWindowGlobal?.innerWindowId || null; + } + + get browsingContext() { + if (this.frameLoader) { + return this.frameLoader.browsingContext; + } + return null; + } + /** + * Note that this overrides webNavigation on XULFrameElement, and duplicates the return value for the non-remote case + */ + get webNavigation() { + return this.isRemoteBrowser + ? this._remoteWebNavigation + : this.docShell && this.docShell.QueryInterface(Ci.nsIWebNavigation); + } + + get webProgress() { + return this.browsingContext?.webProgress; + } + + get sessionHistory() { + return this.webNavigation.sessionHistory; + } + + get contentTitle() { + return this.isRemoteBrowser + ? this.browsingContext?.currentWindowGlobal?.documentTitle + : this.contentDocument.title; + } + + set characterSet(val) { + if (this.isRemoteBrowser) { + this.sendMessageToActor( + "UpdateCharacterSet", + { value: val }, + "BrowserTab" + ); + this._characterSet = val; + } else { + this.docShell.charset = val; + this.docShell.gatherCharsetMenuTelemetry(); + } + } + + get characterSet() { + return this.isRemoteBrowser ? this._characterSet : this.docShell.charset; + } + + get mayEnableCharacterEncodingMenu() { + return this.isRemoteBrowser + ? this._mayEnableCharacterEncodingMenu + : this.docShell.mayEnableCharacterEncodingMenu; + } + + set mayEnableCharacterEncodingMenu(aMayEnable) { + if (this.isRemoteBrowser) { + this._mayEnableCharacterEncodingMenu = aMayEnable; + } + } + + get charsetAutodetected() { + return this.isRemoteBrowser + ? this._charsetAutodetected + : this.docShell.charsetAutodetected; + } + + set charsetAutodetected(aAutodetected) { + if (this.isRemoteBrowser) { + this._charsetAutodetected = aAutodetected; + } + } + + get contentPrincipal() { + return this.isRemoteBrowser + ? this._contentPrincipal + : this.contentDocument.nodePrincipal; + } + + get contentPartitionedPrincipal() { + return this.isRemoteBrowser + ? this._contentPartitionedPrincipal + : this.contentDocument.partitionedPrincipal; + } + + get cookieJarSettings() { + return this.isRemoteBrowser + ? this.browsingContext?.currentWindowGlobal?.cookieJarSettings + : this.contentDocument.cookieJarSettings; + } + + get csp() { + return this.isRemoteBrowser ? this._csp : this.contentDocument.csp; + } + + get contentRequestContextID() { + if (this.isRemoteBrowser) { + return this._contentRequestContextID; + } + try { + return this.contentDocument.documentLoadGroup.requestContextID; + } catch (e) { + return null; + } + } + + get referrerInfo() { + return this.isRemoteBrowser + ? this._referrerInfo + : this.contentDocument.referrerInfo; + } + + set fullZoom(val) { + if (val.toFixed(2) == this.fullZoom.toFixed(2)) { + return; + } + if (this.browsingContext.inRDMPane) { + this._rdmFullZoom = val; + let event = document.createEvent("Events"); + event.initEvent("FullZoomChange", true, false); + this.dispatchEvent(event); + } else { + this.browsingContext.fullZoom = val; + } + } + + get fullZoom() { + if (this.browsingContext.inRDMPane) { + return this._rdmFullZoom; + } + return this.browsingContext.fullZoom; + } + + set textZoom(val) { + if (val.toFixed(2) == this.textZoom.toFixed(2)) { + return; + } + this.browsingContext.textZoom = val; + } + + get textZoom() { + return this.browsingContext.textZoom; + } + + enterResponsiveMode() { + if (this.browsingContext.inRDMPane) { + return; + } + this.browsingContext.inRDMPane = true; + this._rdmFullZoom = this.browsingContext.fullZoom; + this.browsingContext.fullZoom = 1.0; + } + + leaveResponsiveMode() { + if (!this.browsingContext.inRDMPane) { + return; + } + this.browsingContext.inRDMPane = false; + this.browsingContext.fullZoom = this._rdmFullZoom; + } + + get isSyntheticDocument() { + if (this.isRemoteBrowser) { + return this._isSyntheticDocument; + } + return this.contentDocument.mozSyntheticDocument; + } + + get hasContentOpener() { + return !!this.browsingContext.opener; + } + + get mStrBundle() { + if (!this._mStrBundle) { + // need to create string bundle manually instead of using <xul:stringbundle/> + // see bug 63370 for details + this._mStrBundle = Services.strings.createBundle( + "chrome://global/locale/browser.properties" + ); + } + return this._mStrBundle; + } + + get audioMuted() { + return this._audioMuted; + } + + get shouldHandleUnselectedTabHover() { + return this._unselectedTabHoverMessageListenerCount > 0; + } + + set shouldHandleUnselectedTabHover(value) { + this._unselectedTabHoverMessageListenerCount += value ? 1 : -1; + } + + get securityUI() { + return this.browsingContext.secureBrowserUI; + } + + set userTypedValue(val) { + this.urlbarChangeTracker.userTyped(); + this._userTypedValue = val; + } + + get userTypedValue() { + return this._userTypedValue; + } + + get dontPromptAndDontUnload() { + return 1; + } + + get dontPromptAndUnload() { + return 2; + } + + _wrapURIChangeCall(fn) { + if (!this.isRemoteBrowser) { + this.isNavigating = true; + try { + fn(); + } finally { + this.isNavigating = false; + } + } else { + fn(); + } + } + + goBack( + requireUserInteraction = BrowserUtils.navigationRequireUserInteraction + ) { + var webNavigation = this.webNavigation; + if (webNavigation.canGoBack) { + this._wrapURIChangeCall(() => + webNavigation.goBack(requireUserInteraction) + ); + } + } + + goForward( + requireUserInteraction = BrowserUtils.navigationRequireUserInteraction + ) { + var webNavigation = this.webNavigation; + if (webNavigation.canGoForward) { + this._wrapURIChangeCall(() => + webNavigation.goForward(requireUserInteraction) + ); + } + } + + reload() { + const nsIWebNavigation = Ci.nsIWebNavigation; + const flags = nsIWebNavigation.LOAD_FLAGS_NONE; + this.reloadWithFlags(flags); + } + + reloadWithFlags(aFlags) { + this.webNavigation.reload(aFlags); + } + + stop() { + const nsIWebNavigation = Ci.nsIWebNavigation; + const flags = nsIWebNavigation.STOP_ALL; + this.webNavigation.stop(flags); + } + + /** + * throws exception for unknown schemes + */ + loadURI(aURI, aParams = {}) { + if (!aURI) { + aURI = "about:blank"; + } + let { + referrerInfo, + triggeringPrincipal, + postData, + headers, + csp, + } = aParams; + let loadFlags = + aParams.loadFlags || + aParams.flags || + Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let loadURIOptions = { + triggeringPrincipal, + csp, + referrerInfo, + loadFlags, + postData, + headers, + }; + this._wrapURIChangeCall(() => + this.webNavigation.loadURI(aURI, loadURIOptions) + ); + } + + gotoIndex(aIndex) { + this._wrapURIChangeCall(() => this.webNavigation.gotoIndex(aIndex)); + } + + preserveLayers(preserve) { + if (!this.isRemoteBrowser) { + return; + } + let { frameLoader } = this; + if (frameLoader.remoteTab) { + frameLoader.remoteTab.preserveLayers(preserve); + } + } + + deprioritize() { + if (!this.isRemoteBrowser) { + return; + } + let { frameLoader } = this; + if (frameLoader.remoteTab) { + frameLoader.remoteTab.deprioritize(); + } + } + + getTabBrowser() { + if ( + this.ownerGlobal.gBrowser && + this.ownerGlobal.gBrowser.getTabForBrowser && + this.ownerGlobal.gBrowser.getTabForBrowser(this) + ) { + return this.ownerGlobal.gBrowser; + } + return null; + } + + addProgressListener(aListener, aNotifyMask) { + if (!aNotifyMask) { + aNotifyMask = Ci.nsIWebProgress.NOTIFY_ALL; + } + + this.webProgress.addProgressListener(aListener, aNotifyMask); + } + + removeProgressListener(aListener) { + this.webProgress.removeProgressListener(aListener); + } + + onPageHide(aEvent) { + if (!this.docShell || !this.fastFind) { + return; + } + var tabBrowser = this.getTabBrowser(); + if ( + !tabBrowser || + !("fastFind" in tabBrowser) || + tabBrowser.selectedBrowser == this + ) { + this.fastFind.setDocShell(this.docShell); + } + } + + audioPlaybackStarted() { + if (this._audioMuted) { + return; + } + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackStarted", true, false); + this.dispatchEvent(event); + } + + audioPlaybackStopped() { + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackStopped", true, false); + this.dispatchEvent(event); + } + + /** + * When the pref "media.block-autoplay-until-in-foreground" is on, + * Gecko delays starting playback of media resources in tabs until the + * tab has been in the foreground or resumed by tab's play tab icon. + * - When Gecko delays starting playback of a media resource in a window, + * it sends a message to call activeMediaBlockStarted(). This causes the + * tab audio indicator to show. + * - When a tab is foregrounded, Gecko starts playing all delayed media + * resources in that tab, and sends a message to call + * activeMediaBlockStopped(). This causes the tab audio indicator to hide. + */ + activeMediaBlockStarted() { + this._hasAnyPlayingMediaBeenBlocked = true; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStarted", true, false); + this.dispatchEvent(event); + } + + activeMediaBlockStopped() { + if (!this._hasAnyPlayingMediaBeenBlocked) { + return; + } + this._hasAnyPlayingMediaBeenBlocked = false; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStopped", true, false); + this.dispatchEvent(event); + } + + mute(transientState) { + if (!transientState) { + this._audioMuted = true; + } + let context = this.frameLoader.browsingContext; + context.notifyMediaMutedChanged(true); + } + + unmute() { + this._audioMuted = false; + let context = this.frameLoader.browsingContext; + context.notifyMediaMutedChanged(false); + } + + resumeMedia() { + this.frameLoader.browsingContext.notifyStartDelayedAutoplayMedia(); + if (this._hasAnyPlayingMediaBeenBlocked) { + this._hasAnyPlayingMediaBeenBlocked = false; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStopped", true, false); + this.dispatchEvent(event); + } + } + + unselectedTabHover(hovered) { + if (!this.shouldHandleUnselectedTabHover) { + return; + } + this.sendMessageToActor( + "Browser:UnselectedTabHover", + { + hovered, + }, + "UnselectedTabHover", + "roots" + ); + } + + didStartLoadSinceLastUserTyping() { + return ( + !this.isNavigating && + this.urlbarChangeTracker._startedLoadSinceLastUserTyping + ); + } + + construct() { + elementsToDestroyOnUnload.add(this); + this.resetFields(); + this.mInitialized = true; + if (this.isRemoteBrowser) { + /* + * Don't try to send messages from this function. The message manager for + * the <browser> element may not be initialized yet. + */ + + this._remoteWebNavigation = new LazyModules.RemoteWebNavigation(this); + + // Initialize contentPrincipal to the about:blank principal for this loadcontext + let aboutBlank = Services.io.newURI("about:blank"); + let ssm = Services.scriptSecurityManager; + this._contentPrincipal = ssm.getLoadContextContentPrincipal( + aboutBlank, + this.loadContext + ); + // CSP for about:blank is null; if we ever change _contentPrincipal above, + // we should re-evaluate the CSP here. + this._csp = null; + + this.messageManager.loadFrameScript( + "chrome://global/content/browser-child.js", + true + ); + + if (!this.hasAttribute("disablehistory")) { + Services.obs.addObserver( + this.observer, + "browser:purge-session-history", + true + ); + } + } + + try { + // |webNavigation.sessionHistory| will have been set by the frame + // loader when creating the docShell as long as this xul:browser + // doesn't have the 'disablehistory' attribute set. + if (this.docShell && this.webNavigation.sessionHistory) { + Services.obs.addObserver( + this.observer, + "browser:purge-session-history", + true + ); + + // enable global history if we weren't told otherwise + if ( + !this.hasAttribute("disableglobalhistory") && + !this.isRemoteBrowser + ) { + try { + this.docShell.browsingContext.useGlobalHistory = true; + } catch (ex) { + // This can occur if the Places database is locked + Cu.reportError("Error enabling browser global history: " + ex); + } + } + } + } catch (e) { + Cu.reportError(e); + } + try { + // Ensures the securityUI is initialized. + var securityUI = this.securityUI; // eslint-disable-line no-unused-vars + } catch (e) {} + + if (!this.isRemoteBrowser) { + this._remoteWebNavigation = null; + this.addEventListener("pagehide", this.onPageHide, true); + } + } + + /** + * This is necessary because the destructor doesn't always get called when + * we are removed from a tabbrowser. This will be explicitly called by tabbrowser. + */ + destroy() { + elementsToDestroyOnUnload.delete(this); + + // Make sure that any open select is closed. + if (this.hasAttribute("selectmenulist")) { + let menulist = document.getElementById( + this.getAttribute("selectmenulist") + ); + if (menulist && menulist.open) { + let resourcePath = "resource://gre/actors/SelectParent.jsm"; + let { SelectParentHelper } = ChromeUtils.import(resourcePath); + SelectParentHelper.hide(menulist, this); + } + } + + this.resetFields(); + + if (!this.mInitialized) { + return; + } + + this.mInitialized = false; + this.lastURI = null; + + if (!this.isRemoteBrowser) { + this.removeEventListener("pagehide", this.onPageHide, true); + } + } + + updateForStateChange(aCharset, aDocumentURI, aContentType) { + if (this.isRemoteBrowser && this.messageManager) { + if (aCharset != null) { + this._characterSet = aCharset; + } + + if (aDocumentURI != null) { + this._documentURI = aDocumentURI; + } + + if (aContentType != null) { + this._documentContentType = aContentType; + } + } + } + + updateWebNavigationForLocationChange(aCanGoBack, aCanGoForward) { + if ( + this.isRemoteBrowser && + this.messageManager && + !Services.appinfo.sessionHistoryInParent + ) { + this._remoteWebNavigation._canGoBack = aCanGoBack; + this._remoteWebNavigation._canGoForward = aCanGoForward; + } + } + + updateForLocationChange( + aLocation, + aCharset, + aMayEnableCharacterEncodingMenu, + aCharsetAutodetected, + aDocumentURI, + aTitle, + aContentPrincipal, + aContentPartitionedPrincipal, + aCSP, + aReferrerInfo, + aIsSynthetic, + aHaveRequestContextID, + aRequestContextID, + aContentType + ) { + if (this.isRemoteBrowser && this.messageManager) { + if (aCharset != null) { + this._characterSet = aCharset; + this._mayEnableCharacterEncodingMenu = aMayEnableCharacterEncodingMenu; + this._charsetAutodetected = aCharsetAutodetected; + } + + if (aContentType != null) { + this._documentContentType = aContentType; + } + + this._remoteWebNavigation._currentURI = aLocation; + this._documentURI = aDocumentURI; + this._contentPrincipal = aContentPrincipal; + this._contentPartitionedPrincipal = aContentPartitionedPrincipal; + this._csp = aCSP; + this._referrerInfo = aReferrerInfo; + this._isSyntheticDocument = aIsSynthetic; + this._contentRequestContextID = aHaveRequestContextID + ? aRequestContextID + : null; + } + } + + purgeSessionHistory() { + if (this.isRemoteBrowser && !Services.appinfo.sessionHistoryInParent) { + this._remoteWebNavigation._canGoBack = false; + this._remoteWebNavigation._canGoForward = false; + } + + try { + if (Services.appinfo.sessionHistoryInParent) { + let sessionHistory = this.browsingContext?.sessionHistory; + if (!sessionHistory) { + return; + } + + // place the entry at current index at the end of the history list, so it won't get removed + if (sessionHistory.index < sessionHistory.count - 1) { + let indexEntry = sessionHistory.getEntryAtIndex( + sessionHistory.index + ); + sessionHistory.addEntry(indexEntry, true); + } + + let purge = sessionHistory.count; + if ( + this.browsingContext.currentWindowGlobal.documentURI != + "about:blank" + ) { + --purge; // Don't remove the page the user's staring at from shistory + } + + if (purge > 0) { + sessionHistory.purgeHistory(purge); + } + + return; + } + + this.sendMessageToActor( + "Browser:PurgeSessionHistory", + {}, + "PurgeSessionHistory", + "roots" + ); + } catch (ex) { + // This can throw if the browser has started to go away. + if (ex.result != Cr.NS_ERROR_NOT_INITIALIZED) { + throw ex; + } + } + } + + createAboutBlankContentViewer(aPrincipal, aPartitionedPrincipal) { + if (this.isRemoteBrowser) { + // Ensure that the content process has the permissions which are + // needed to create a document with the given principal. + let permissionPrincipal = BrowserUtils.principalWithMatchingOA( + aPrincipal, + this.contentPrincipal + ); + this.frameLoader.remoteTab.transmitPermissionsForPrincipal( + permissionPrincipal + ); + + // This still uses the message manager, for the following reasons: + // + // 1. Due to bug 1523638, it's virtually certain that, if we've just created + // this <xul:browser>, that the WindowGlobalParent for the top-level frame + // of this browser doesn't exist yet, so it's not possible to get at a + // JS Window Actor for it. + // + // 2. JS Window Actors are tied to the principals for the frames they're running + // in - switching principals is therefore self-destructive and unexpected. + // + // So we'll continue to use the message manager until we come up with a better + // solution. + this.messageManager.sendAsyncMessage( + "BrowserElement:CreateAboutBlank", + { principal: aPrincipal, partitionedPrincipal: aPartitionedPrincipal } + ); + return; + } + let principal = BrowserUtils.principalWithMatchingOA( + aPrincipal, + this.contentPrincipal + ); + let partitionedPrincipal = BrowserUtils.principalWithMatchingOA( + aPartitionedPrincipal, + this.contentPartitionedPrincipal + ); + this.docShell.createAboutBlankContentViewer( + principal, + partitionedPrincipal + ); + } + + stopScroll() { + if (this._autoScrollBrowsingContext) { + window.removeEventListener("mousemove", this, true); + window.removeEventListener("mousedown", this, true); + window.removeEventListener("mouseup", this, true); + window.removeEventListener("DOMMouseScroll", this, true); + window.removeEventListener("contextmenu", this, true); + window.removeEventListener("keydown", this, true); + window.removeEventListener("keypress", this, true); + window.removeEventListener("keyup", this, true); + + let autoScrollWnd = this._autoScrollBrowsingContext.currentWindowGlobal; + if (autoScrollWnd) { + autoScrollWnd + .getActor("AutoScroll") + .sendAsyncMessage("Autoscroll:Stop", {}); + } + this._autoScrollBrowsingContext = null; + + try { + Services.obs.removeObserver(this.observer, "apz:cancel-autoscroll"); + } catch (ex) { + // It's not clear why this sometimes throws an exception + } + + if (this.isRemoteBrowser && this._autoScrollScrollId != null) { + let { remoteTab } = this.frameLoader; + if (remoteTab) { + remoteTab.stopApzAutoscroll( + this._autoScrollScrollId, + this._autoScrollPresShellId + ); + } + this._autoScrollScrollId = null; + this._autoScrollPresShellId = null; + } + } + } + + _getAndMaybeCreateAutoScrollPopup() { + let autoscrollPopup = document.getElementById("autoscroller"); + if (!autoscrollPopup) { + autoscrollPopup = document.createXULElement("panel"); + autoscrollPopup.className = "autoscroller"; + autoscrollPopup.setAttribute("consumeoutsideclicks", "true"); + autoscrollPopup.setAttribute("rolluponmousewheel", "true"); + autoscrollPopup.id = "autoscroller"; + } + + return autoscrollPopup; + } + + startScroll({ + scrolldir, + screenX, + screenY, + scrollId, + presShellId, + browsingContext, + }) { + if (!this.autoscrollEnabled) { + return { autoscrollEnabled: false, usingApz: false }; + } + + const POPUP_SIZE = 32; + if (!this._autoScrollPopup) { + this._autoScrollPopup = this._getAndMaybeCreateAutoScrollPopup(); + document.documentElement.appendChild(this._autoScrollPopup); + this._autoScrollPopup.removeAttribute("hidden"); + this._autoScrollPopup.setAttribute("noautofocus", "true"); + this._autoScrollPopup.style.height = POPUP_SIZE + "px"; + this._autoScrollPopup.style.width = POPUP_SIZE + "px"; + this._autoScrollPopup.style.margin = -POPUP_SIZE / 2 + "px"; + } + + let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + let screen = screenManager.screenForRect(screenX, screenY, 1, 1); + + // we need these attributes so themers don't need to create per-platform packages + if (screen.colorDepth > 8) { + // need high color for transparency + // Exclude second-rate platforms + this._autoScrollPopup.setAttribute( + "transparent", + !/BeOS|OS\/2/.test(navigator.appVersion) + ); + // Enable translucency on Windows and Mac + this._autoScrollPopup.setAttribute( + "translucent", + AppConstants.platform == "win" || AppConstants.platform == "macosx" + ); + } + + this._autoScrollPopup.setAttribute("scrolldir", scrolldir); + this._autoScrollPopup.addEventListener("popuphidden", this, true); + + // Sanitize screenX/screenY for available screen size with half the size + // of the popup removed. The popup uses negative margins to center on the + // coordinates we pass. Unfortunately `window.screen.availLeft` can be negative + // on Windows in multi-monitor setups, so we use nsIScreenManager instead: + let left = {}, + top = {}, + width = {}, + height = {}; + screen.GetAvailRect(left, top, width, height); + + // We need to get screen CSS-pixel (rather than display-pixel) coordinates. + // With 175% DPI, the actual ratio of display pixels to CSS pixels is + // 1.7647 because of rounding inside gecko. Unfortunately defaultCSSScaleFactor + // returns the original 1.75 dpi factor. While window.devicePixelRatio would + // get us the correct ratio, if the window is split between 2 screens, + // window.devicePixelRatio isn't guaranteed to match the screen we're + // autoscrolling on. So instead we do the same math as Gecko. + const scaleFactor = 60 / Math.round(60 / screen.defaultCSSScaleFactor); + let minX = left.value / scaleFactor + 0.5 * POPUP_SIZE; + let maxX = (left.value + width.value) / scaleFactor - 0.5 * POPUP_SIZE; + let minY = top.value / scaleFactor + 0.5 * POPUP_SIZE; + let maxY = (top.value + height.value) / scaleFactor - 0.5 * POPUP_SIZE; + let popupX = Math.max(minX, Math.min(maxX, screenX)); + let popupY = Math.max(minY, Math.min(maxY, screenY)); + this._autoScrollPopup.openPopupAtScreen(popupX, popupY); + this._ignoreMouseEvents = true; + this._startX = screenX; + this._startY = screenY; + this._autoScrollBrowsingContext = browsingContext; + + window.addEventListener("mousemove", this, true); + window.addEventListener("mousedown", this, true); + window.addEventListener("mouseup", this, true); + window.addEventListener("DOMMouseScroll", this, true); + window.addEventListener("contextmenu", this, true); + window.addEventListener("keydown", this, true); + window.addEventListener("keypress", this, true); + window.addEventListener("keyup", this, true); + + let usingApz = false; + if ( + this.isRemoteBrowser && + scrollId != null && + this.mPrefs.getBoolPref("apz.autoscroll.enabled", false) + ) { + let { remoteTab } = this.frameLoader; + if (remoteTab) { + // If APZ is handling the autoscroll, it may decide to cancel + // it of its own accord, so register an observer to allow it + // to notify us of that. + Services.obs.addObserver( + this.observer, + "apz:cancel-autoscroll", + true + ); + + usingApz = remoteTab.startApzAutoscroll( + screenX, + screenY, + scrollId, + presShellId + ); + } + // Save the IDs for later + this._autoScrollScrollId = scrollId; + this._autoScrollPresShellId = presShellId; + } + + return { autoscrollEnabled: true, usingApz }; + } + + cancelScroll() { + this._autoScrollPopup.hidePopup(); + } + + handleEvent(aEvent) { + if (this._autoScrollBrowsingContext) { + switch (aEvent.type) { + case "mousemove": { + var x = aEvent.screenX - this._startX; + var y = aEvent.screenY - this._startY; + + if ( + x > this._AUTOSCROLL_SNAP || + x < -this._AUTOSCROLL_SNAP || + y > this._AUTOSCROLL_SNAP || + y < -this._AUTOSCROLL_SNAP + ) { + this._ignoreMouseEvents = false; + } + break; + } + case "mouseup": + case "mousedown": + case "contextmenu": { + if (!this._ignoreMouseEvents) { + // Use a timeout to prevent the mousedown from opening the popup again. + // Ideally, we could use preventDefault here, but contenteditable + // and middlemouse paste don't interact well. See bug 1188536. + setTimeout(() => this._autoScrollPopup.hidePopup(), 0); + } + this._ignoreMouseEvents = false; + break; + } + case "DOMMouseScroll": { + this._autoScrollPopup.hidePopup(); + aEvent.preventDefault(); + break; + } + case "popuphidden": { + this._autoScrollPopup.removeEventListener( + "popuphidden", + this, + true + ); + this.stopScroll(); + break; + } + case "keydown": { + if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) { + // the escape key will be processed by + // nsXULPopupManager::KeyDown and the panel will be closed. + // So, don't consume the key event here. + break; + } + // don't break here. we need to eat keydown events. + } + // fall through + case "keypress": + case "keyup": { + // All keyevents should be eaten here during autoscrolling. + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + } + } + } + } + + closeBrowser() { + // The request comes from a XPCOM component, we'd want to redirect + // the request to tabbrowser. + let tabbrowser = this.getTabBrowser(); + if (tabbrowser) { + let tab = tabbrowser.getTabForBrowser(this); + if (tab) { + tabbrowser.removeTab(tab); + return; + } + } + + throw new Error( + "Closing a browser which was not attached to a tabbrowser is unsupported." + ); + } + + swapBrowsers(aOtherBrowser) { + // The request comes from a XPCOM component, we'd want to redirect + // the request to tabbrowser so tabbrowser will be setup correctly, + // and it will eventually call swapDocShells. + let ourTabBrowser = this.getTabBrowser(); + let otherTabBrowser = aOtherBrowser.getTabBrowser(); + if (ourTabBrowser && otherTabBrowser) { + let ourTab = ourTabBrowser.getTabForBrowser(this); + let otherTab = otherTabBrowser.getTabForBrowser(aOtherBrowser); + ourTabBrowser.swapBrowsers(ourTab, otherTab); + return; + } + + // One of us is not connected to a tabbrowser, so just swap. + this.swapDocShells(aOtherBrowser); + } + + swapDocShells(aOtherBrowser) { + if (this.isRemoteBrowser != aOtherBrowser.isRemoteBrowser) { + throw new Error( + "Can only swap docshells between browsers in the same process." + ); + } + + // Give others a chance to swap state. + // IMPORTANT: Since a swapDocShells call does not swap the messageManager + // instances attached to a browser to aOtherBrowser, others + // will need to add the message listeners to the new + // messageManager. + // This is not a bug in swapDocShells or the FrameLoader, + // merely a design decision: If message managers were swapped, + // so that no new listeners were needed, the new + // aOtherBrowser.messageManager would have listeners pointing + // to the JS global of the current browser, which would rather + // easily create leaks while swapping. + // IMPORTANT2: When the current browser element is removed from DOM, + // which is quite common after a swapDocShells call, its + // frame loader is destroyed, and that destroys the relevant + // message manager, which will remove the listeners. + let event = new CustomEvent("SwapDocShells", { detail: aOtherBrowser }); + this.dispatchEvent(event); + event = new CustomEvent("SwapDocShells", { detail: this }); + aOtherBrowser.dispatchEvent(event); + + // We need to swap fields that are tied to our docshell or related to + // the loaded page + // Fields which are built as a result of notifactions (pageshow/hide, + // DOMLinkAdded/Removed, onStateChange) should not be swapped here, + // because these notifications are dispatched again once the docshells + // are swapped. + var fieldsToSwap = ["_webBrowserFind", "_rdmFullZoom"]; + + if (this.isRemoteBrowser) { + fieldsToSwap.push( + ...[ + "_remoteWebNavigation", + "_remoteFinder", + "_documentURI", + "_documentContentType", + "_characterSet", + "_mayEnableCharacterEncodingMenu", + "_charsetAutodetected", + "_contentPrincipal", + "_contentPartitionedPrincipal", + "_isSyntheticDocument", + ] + ); + } + + var ourFieldValues = {}; + var otherFieldValues = {}; + for (let field of fieldsToSwap) { + ourFieldValues[field] = this[field]; + otherFieldValues[field] = aOtherBrowser[field]; + } + + if (window.PopupNotifications) { + PopupNotifications._swapBrowserNotifications(aOtherBrowser, this); + } + + try { + this.swapFrameLoaders(aOtherBrowser); + } catch (ex) { + // This may not be implemented for browser elements that are not + // attached to a BrowserDOMWindow. + } + + for (let field of fieldsToSwap) { + this[field] = otherFieldValues[field]; + aOtherBrowser[field] = ourFieldValues[field]; + } + + if (!this.isRemoteBrowser) { + // Null the current nsITypeAheadFind instances so that they're + // lazily re-created on access. We need to do this because they + // might have attached the wrong docShell. + this._fastFind = aOtherBrowser._fastFind = null; + } else { + // Rewire the remote listeners + this._remoteWebNavigation.swapBrowser(this); + aOtherBrowser._remoteWebNavigation.swapBrowser(aOtherBrowser); + + if (this._remoteFinder) { + this._remoteFinder.swapBrowser(this); + } + if (aOtherBrowser._remoteFinder) { + aOtherBrowser._remoteFinder.swapBrowser(aOtherBrowser); + } + } + + event = new CustomEvent("EndSwapDocShells", { detail: aOtherBrowser }); + this.dispatchEvent(event); + event = new CustomEvent("EndSwapDocShells", { detail: this }); + aOtherBrowser.dispatchEvent(event); + } + + getInPermitUnload(aCallback) { + if (this.isRemoteBrowser) { + let { remoteTab } = this.frameLoader; + if (!remoteTab) { + // If we're crashed, we're definitely not in this state anymore. + aCallback(false); + return; + } + + aCallback( + this._inPermitUnload.has(this.browsingContext.currentWindowGlobal) + ); + return; + } + + if (!this.docShell || !this.docShell.contentViewer) { + aCallback(false); + return; + } + aCallback(this.docShell.contentViewer.inPermitUnload); + } + + async asyncPermitUnload(action) { + let wgp = this.browsingContext.currentWindowGlobal; + if (this._inPermitUnload.has(wgp)) { + throw new Error("permitUnload is already running for this tab."); + } + + this._inPermitUnload.add(wgp); + try { + let permitUnload = await wgp.permitUnload( + action, + lazyPrefs.unloadTimeoutMs + ); + return { permitUnload }; + } finally { + this._inPermitUnload.delete(wgp); + } + } + + get hasBeforeUnload() { + function hasBeforeUnload(bc) { + if (bc.currentWindowContext?.hasBeforeUnload) { + return true; + } + return bc.children.some(hasBeforeUnload); + } + return hasBeforeUnload(this.browsingContext); + } + + permitUnload(action) { + if (this.isRemoteBrowser) { + if (!this.hasBeforeUnload) { + return { permitUnload: true }; + } + + let result; + let success; + + this.asyncPermitUnload(action).then( + val => { + result = val; + success = true; + }, + err => { + result = err; + success = false; + } + ); + + // The permitUnload() promise will, alas, not call its resolution + // callbacks after the browser window the promise lives in has closed, + // so we have to check for that case explicitly. + Services.tm.spinEventLoopUntilOrShutdown( + () => window.closed || success !== undefined + ); + if (success) { + return result; + } + throw result; + } + + if (!this.docShell || !this.docShell.contentViewer) { + return { permitUnload: true }; + } + return { + permitUnload: this.docShell.contentViewer.permitUnload(), + }; + } + + print(aOuterWindowID, aPrintSettings) { + if (!this.frameLoader) { + throw Components.Exception("No frame loader.", Cr.NS_ERROR_FAILURE); + } + + return this.frameLoader.print(aOuterWindowID, aPrintSettings); + } + + async drawSnapshot(x, y, w, h, scale, backgroundColor) { + let rect = new DOMRect(x, y, w, h); + try { + return this.browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + backgroundColor + ); + } catch (e) { + return false; + } + } + + dropLinks(aLinks, aTriggeringPrincipal) { + if (!this.droppedLinkHandler) { + return false; + } + let links = []; + for (let i = 0; i < aLinks.length; i += 3) { + links.push({ + url: aLinks[i], + name: aLinks[i + 1], + type: aLinks[i + 2], + }); + } + this.droppedLinkHandler(null, links, aTriggeringPrincipal); + return true; + } + + getContentBlockingLog() { + let windowGlobal = this.browsingContext.currentWindowGlobal; + if (!windowGlobal) { + return null; + } + return windowGlobal.contentBlockingLog; + } + + getContentBlockingEvents() { + let windowGlobal = this.browsingContext.currentWindowGlobal; + if (!windowGlobal) { + return 0; + } + return windowGlobal.contentBlockingEvents; + } + + // Send an asynchronous message to the remote child via an actor. + // Note: use this only for messages through an actor. For old-style + // messages, use the message manager. + // The value of the scope argument determines which browsing contexts + // are sent to: + // 'all' - send to actors associated with all descendant child frames. + // 'roots' - send only to actors associated with process roots. + // undefined/'' - send only to the top-level actor and not any descendants. + sendMessageToActor(messageName, args, actorName, scope) { + if (!this.frameLoader) { + return; + } + + function sendToChildren(browsingContext, childScope) { + let windowGlobal = browsingContext.currentWindowGlobal; + // If 'roots' is set, only send if windowGlobal.isProcessRoot is true. + if ( + windowGlobal && + (childScope != "roots" || windowGlobal.isProcessRoot) + ) { + windowGlobal.getActor(actorName).sendAsyncMessage(messageName, args); + } + + // Iterate as long as scope in assigned. Note that we use the original + // passed in scope, not childScope here. + if (scope) { + for (let context of browsingContext.children) { + sendToChildren(context, scope); + } + } + } + + // Pass no second argument to always send to the top-level browsing context. + sendToChildren(this.browsingContext); + } + + enterModalState() { + this.sendMessageToActor("EnterModalState", {}, "BrowserElement", "roots"); + } + + leaveModalState() { + this.sendMessageToActor( + "LeaveModalState", + { forceLeave: true }, + "BrowserElement", + "roots" + ); + } + + /** + * Can be called for a window with or without modal state. + * If the window is not in modal state, this is a no-op. + */ + maybeLeaveModalState() { + this.sendMessageToActor( + "LeaveModalState", + { forceLeave: false }, + "BrowserElement", + "roots" + ); + } + + getDevicePermissionOrigins(key) { + if (typeof key !== "string" || key.length === 0) { + throw new Error("Key must be non empty string."); + } + if (!this._devicePermissionOrigins) { + this._devicePermissionOrigins = new Map(); + } + let origins = this._devicePermissionOrigins.get(key); + if (!origins) { + origins = new Set(); + this._devicePermissionOrigins.set(key, origins); + } + return origins; + } + + get processSwitchBehavior() { + // If a `remotenessChangeHandler` is attached to this browser, it supports + // having its toplevel process switched dynamically in response to + // navigations. + if (this.hasAttribute("maychangeremoteness")) { + return Ci.nsIBrowser.PROCESS_BEHAVIOR_STANDARD; + } + + // For backwards compatibility, we need to mark remote, but + // non-`allowremote`, frames as `PROCESS_BEHAVIOR_SUBFRAME_ONLY`, as some + // tests rely on it. + // FIXME: Remove this? + if (this.isRemoteBrowser) { + return Ci.nsIBrowser.PROCESS_BEHAVIOR_SUBFRAME_ONLY; + } + // Otherwise, don't allow gecko-initiated toplevel process switches. + return Ci.nsIBrowser.PROCESS_BEHAVIOR_DISABLED; + } + + // This method is replaced by frontend code in order to delay performing the + // process switch until some async operatin is completed. + // + // This is used by tabbrowser to flush SessionStore before a process switch. + async prepareToChangeRemoteness() { + /* no-op unless replaced */ + } + + // This method is replaced by frontend code in order to handle restoring + // remote session history + // + // Called immediately after changing remoteness. If this method returns + // `true`, Gecko will assume frontend handled resuming the load, and will + // not attempt to resume the load itself. + afterChangeRemoteness(browser, redirectLoadSwitchId) { + /* no-op unless replaced */ + return false; + } + + // Called by Gecko before the remoteness change happens, allowing for + // listeners, etc. to be stashed before the process switch. + beforeChangeRemoteness() { + // Fire the `WillChangeBrowserRemoteness` event, which may be hooked by + // frontend code for custom behaviour. + let event = document.createEvent("Events"); + event.initEvent("WillChangeBrowserRemoteness", true, false); + this.dispatchEvent(event); + + // Destroy ourselves to unregister from observer notifications + // FIXME: Can we get away with something less destructive here? + this.destroy(); + } + + finishChangeRemoteness(redirectLoadSwitchId) { + // Re-construct ourselves after the destroy in `beforeChangeRemoteness`. + this.construct(); + + // Fire the `DidChangeBrowserRemoteness` event, which may be hooked by + // frontend code for custom behaviour. + let event = document.createEvent("Events"); + event.initEvent("DidChangeBrowserRemoteness", true, false); + this.dispatchEvent(event); + + // Call into frontend code which may want to handle the load (e.g. to + // while restoring session state). + return this.afterChangeRemoteness(redirectLoadSwitchId); + } + } + + MozXULElement.implementCustomInterface(MozBrowser, [Ci.nsIBrowser]); + customElements.define("browser", MozBrowser); +} diff --git a/toolkit/content/widgets/button.js b/toolkit/content/widgets/button.js new file mode 100644 index 0000000000..b3873d8646 --- /dev/null +++ b/toolkit/content/widgets/button.js @@ -0,0 +1,317 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + class MozButtonBase extends MozElements.BaseText { + constructor() { + super(); + + /** + * While it would seem we could do this by handling oncommand, we can't + * because any external oncommand handlers might get called before ours, + * and then they would see the incorrect value of checked. Additionally + * a command attribute would redirect the command events anyway. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + this._handleClick(); + }); + + this.addEventListener("keypress", event => { + if (event.key != " ") { + return; + } + this._handleClick(); + // Prevent page from scrolling on the space key. + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if (this.hasMenu()) { + if (this.open) { + return; + } + } else { + if ( + event.keyCode == KeyEvent.DOM_VK_UP || + (event.keyCode == KeyEvent.DOM_VK_LEFT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "ltr") || + (event.keyCode == KeyEvent.DOM_VK_RIGHT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "rtl") + ) { + event.preventDefault(); + window.document.commandDispatcher.rewindFocus(); + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_DOWN || + (event.keyCode == KeyEvent.DOM_VK_RIGHT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "ltr") || + (event.keyCode == KeyEvent.DOM_VK_LEFT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "rtl") + ) { + event.preventDefault(); + window.document.commandDispatcher.advanceFocus(); + return; + } + } + + if ( + event.keyCode || + event.charCode <= 32 || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } // No printable char pressed, not a potential accesskey + + // Possible accesskey pressed + var charPressedLower = String.fromCharCode( + event.charCode + ).toLowerCase(); + + // If the accesskey of the current button is pressed, just activate it + if (this.accessKey.toLowerCase() == charPressedLower) { + this.click(); + return; + } + + // Search for accesskey in the list of buttons for this doc and each subdoc + // Get the buttons for the main document and all sub-frames + for ( + var frameCount = -1; + frameCount < window.top.frames.length; + frameCount++ + ) { + var doc = + frameCount == -1 + ? window.top.document + : window.top.frames[frameCount].document; + if (this.fireAccessKeyButton(doc.documentElement, charPressedLower)) { + return; + } + } + + // Test dialog buttons + let buttonBox = window.top.document.documentElement.buttonBox; + if (buttonBox) { + this.fireAccessKeyButton(buttonBox, charPressedLower); + } + }); + } + + set type(val) { + this.setAttribute("type", val); + return val; + } + + get type() { + return this.getAttribute("type"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + return val; + } + + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set group(val) { + this.setAttribute("group", val); + return val; + } + + get group() { + return this.getAttribute("group"); + } + + set open(val) { + if (this.hasMenu()) { + this.openMenu(val); + } else if (val) { + // Fall back to just setting the attribute + this.setAttribute("open", "true"); + } else { + this.removeAttribute("open"); + } + return val; + } + + get open() { + return this.hasAttribute("open"); + } + + set checked(val) { + if (this.type == "radio" && val) { + var sibs = this.parentNode.getElementsByAttribute("group", this.group); + for (var i = 0; i < sibs.length; ++i) { + sibs[i].removeAttribute("checked"); + } + } + + if (val) { + this.setAttribute("checked", "true"); + } else { + this.removeAttribute("checked"); + } + + return val; + } + + get checked() { + return this.hasAttribute("checked"); + } + + filterButtons(node) { + // if the node isn't visible, don't descend into it. + var cs = node.ownerGlobal.getComputedStyle(node); + if (cs.visibility != "visible" || cs.display == "none") { + return NodeFilter.FILTER_REJECT; + } + // but it may be a popup element, in which case we look at "state"... + if (cs.display == "-moz-popup" && node.state != "open") { + return NodeFilter.FILTER_REJECT; + } + // OK - the node seems visible, so it is a candidate. + if (node.localName == "button" && node.accessKey && !node.disabled) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + } + + fireAccessKeyButton(aSubtree, aAccessKeyLower) { + var iterator = aSubtree.ownerDocument.createTreeWalker( + aSubtree, + NodeFilter.SHOW_ELEMENT, + this.filterButtons + ); + while (iterator.nextNode()) { + var test = iterator.currentNode; + if ( + test.accessKey.toLowerCase() == aAccessKeyLower && + !test.disabled && + !test.collapsed && + !test.hidden + ) { + test.focus(); + test.click(); + return true; + } + } + return false; + } + + _handleClick() { + if (!this.disabled) { + if (this.type == "checkbox") { + this.checked = !this.checked; + } else if (this.type == "radio") { + this.checked = true; + } + } + } + } + + MozXULElement.implementCustomInterface(MozButtonBase, [ + Ci.nsIDOMXULButtonElement, + ]); + + MozElements.ButtonBase = MozButtonBase; + + class MozButton extends MozButtonBase { + static get inheritedAttributes() { + return { + ".box-inherit": "align,dir,pack,orient", + ".button-icon": "src=image", + ".button-text": "value=label,accesskey,crop", + ".button-menu-dropmarker": "open,disabled,label", + }; + } + + get icon() { + return this.querySelector(".button-icon"); + } + + static get buttonFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="box-inherit button-box" align="center" pack="center" flex="1" anonid="button-box"> + <image class="button-icon"/> + <label class="button-text"/> + </hbox>`), + true + ); + Object.defineProperty(this, "buttonFragment", { value: frag }); + return frag; + } + + static get menuFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="box-inherit button-box" align="center" pack="center" flex="1"> + <hbox class="box-inherit" align="center" pack="center" flex="1"> + <image class="button-icon"/> + <label class="button-text"/> + </hbox> + <dropmarker class="button-menu-dropmarker"/> + </hbox>`), + true + ); + Object.defineProperty(this, "menuFragment", { value: frag }); + return frag; + } + + get _hasConnected() { + return this.querySelector(":scope > .button-box") != null; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + + let fragment; + if (this.type === "menu") { + fragment = MozButton.menuFragment; + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN && event.key != " ") { + return; + } + + this.open = true; + // Prevent page from scrolling on the space key. + if (event.key == " ") { + event.preventDefault(); + } + }); + } else { + fragment = this.constructor.buttonFragment; + } + + this.appendChild(fragment.cloneNode(true)); + this.initializeAttributeInheritance(); + } + } + + customElements.define("button", MozButton); +} diff --git a/toolkit/content/widgets/calendar.js b/toolkit/content/widgets/calendar.js new file mode 100644 index 0000000000..707dc9b818 --- /dev/null +++ b/toolkit/content/widgets/calendar.js @@ -0,0 +1,170 @@ +/* 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"; + +/** + * Initialize the Calendar and generate nodes for week headers and days, and + * attach event listeners. + * + * @param {Object} options + * { + * {Number} calViewSize: Number of days to appear on a calendar view + * {Function} getDayString: Transform day number to string + * {Function} getWeekHeaderString: Transform day of week number to string + * {Function} setSelection: Set selection for dateKeeper + * } + * @param {Object} context + * { + * {DOMElement} weekHeader + * {DOMElement} daysView + * } + */ +function Calendar(options, context) { + const DAYS_IN_A_WEEK = 7; + + this.context = context; + this.state = { + days: [], + weekHeaders: [], + setSelection: options.setSelection, + getDayString: options.getDayString, + getWeekHeaderString: options.getWeekHeaderString, + }; + this.elements = { + weekHeaders: this._generateNodes(DAYS_IN_A_WEEK, context.weekHeader), + daysView: this._generateNodes(options.calViewSize, context.daysView), + }; + + this._attachEventListeners(); +} + +Calendar.prototype = { + /** + * Set new properties and render them. + * + * @param {Object} props + * { + * {Boolean} isVisible: Whether or not the calendar is in view + * {Array<Object>} days: Data for days + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + * {Array<Object>} weekHeaders: Data for weekHeaders + * { + * {Number} content + * {Array<String>} classNames + * } + * } + */ + setProps(props) { + if (props.isVisible) { + // Transform the days and weekHeaders array for rendering + const days = props.days.map( + ({ dateObj, content, classNames, enabled }) => { + return { + dateObj, + textContent: this.state.getDayString(content), + className: classNames.join(" "), + enabled, + }; + } + ); + const weekHeaders = props.weekHeaders.map(({ content, classNames }) => { + return { + textContent: this.state.getWeekHeaderString(content), + className: classNames.join(" "), + }; + }); + // Update the DOM nodes states + this._render({ + elements: this.elements.daysView, + items: days, + prevState: this.state.days, + }); + this._render({ + elements: this.elements.weekHeaders, + items: weekHeaders, + prevState: this.state.weekHeaders, + }); + // Update the state to current + this.state.days = days; + this.state.weekHeaders = weekHeaders; + } + }, + + /** + * Render the items onto the DOM nodes + * @param {Object} + * { + * {Array<DOMElement>} elements + * {Array<Object>} items + * {Array<Object>} prevState: state of items from last render + * } + */ + _render({ elements, items, prevState }) { + for (let i = 0, l = items.length; i < l; i++) { + let el = elements[i]; + + // Check if state from last render has changed, if so, update the elements + if (!prevState[i] || prevState[i].textContent != items[i].textContent) { + el.textContent = items[i].textContent; + } + if (!prevState[i] || prevState[i].className != items[i].className) { + el.className = items[i].className; + } + } + }, + + /** + * Generate DOM nodes + * + * @param {Number} size: Number of nodes to generate + * @param {DOMElement} context: Element to append the nodes to + * @return {Array<DOMElement>} + */ + _generateNodes(size, context) { + let frag = document.createDocumentFragment(); + let refs = []; + + for (let i = 0; i < size; i++) { + let el = document.createElement("div"); + el.dataset.id = i; + refs.push(el); + frag.appendChild(el); + } + context.appendChild(frag); + + return refs; + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + if (event.target.parentNode == this.context.daysView) { + let targetId = event.target.dataset.id; + let targetObj = this.state.days[targetId]; + if (targetObj.enabled) { + this.state.setSelection(targetObj.dateObj); + } + } + break; + } + } + }, + + /** + * Attach event listener to daysView + */ + _attachEventListeners() { + this.context.daysView.addEventListener("click", this); + }, +}; diff --git a/toolkit/content/widgets/checkbox.js b/toolkit/content/widgets/checkbox.js new file mode 100644 index 0000000000..8b695dbe0e --- /dev/null +++ b/toolkit/content/widgets/checkbox.js @@ -0,0 +1,84 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + class MozCheckbox extends MozElements.BaseText { + static get markup() { + return ` + <image class="checkbox-check"/> + <hbox class="checkbox-label-box" flex="1"> + <image class="checkbox-icon"/> + <label class="checkbox-label" flex="1"/> + </hbox> + `; + } + + constructor() { + super(); + + // While it would seem we could do this by handling oncommand, we need can't + // because any external oncommand handlers might get called before ours, and + // then they would see the incorrect value of checked. + this.addEventListener("click", event => { + if (event.button === 0 && !this.disabled) { + this.checked = !this.checked; + } + }); + this.addEventListener("keypress", event => { + if (event.key == " ") { + this.checked = !this.checked; + // Prevent page from scrolling on the space key. + event.preventDefault(); + } + }); + } + + static get inheritedAttributes() { + return { + ".checkbox-check": "disabled,checked", + ".checkbox-label": "text=label,accesskey", + ".checkbox-icon": "src", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + this.initializeAttributeInheritance(); + } + + set checked(val) { + let change = val != (this.getAttribute("checked") == "true"); + if (val) { + this.setAttribute("checked", "true"); + } else { + this.removeAttribute("checked"); + } + + if (change) { + let event = document.createEvent("Events"); + event.initEvent("CheckboxStateChange", true, true); + this.dispatchEvent(event); + } + return val; + } + + get checked() { + return this.getAttribute("checked") == "true"; + } + } + + MozCheckbox.contentFragment = null; + + customElements.define("checkbox", MozCheckbox); +} diff --git a/toolkit/content/widgets/datekeeper.js b/toolkit/content/widgets/datekeeper.js new file mode 100644 index 0000000000..9ba08aeb37 --- /dev/null +++ b/toolkit/content/widgets/datekeeper.js @@ -0,0 +1,381 @@ +/* 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"; + +/** + * DateKeeper keeps track of the date states. + */ +function DateKeeper(props) { + this.init(props); +} + +{ + const DAYS_IN_A_WEEK = 7, + MONTHS_IN_A_YEAR = 12, + YEAR_VIEW_SIZE = 200, + YEAR_BUFFER_SIZE = 10, + // The min value is 0001-01-01 based on HTML spec: + // https://html.spec.whatwg.org/#valid-date-string + MIN_DATE = -62135596800000, + // The max value is derived from the ECMAScript spec (275760-09-13): + // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1 + MAX_DATE = 8640000000000000, + MAX_YEAR = 275760, + MAX_MONTH = 9; + + DateKeeper.prototype = { + get year() { + return this.state.dateObj.getUTCFullYear(); + }, + + get month() { + return this.state.dateObj.getUTCMonth(); + }, + + get selection() { + return this.state.selection; + }, + + /** + * Initialize DateKeeper + * @param {Number} year + * @param {Number} month + * @param {Number} day + * @param {Number} min + * @param {Number} max + * @param {Number} step + * @param {Number} stepBase + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @param {Number} calViewSize + */ + init({ + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek = 0, + weekends = [0], + calViewSize = 42, + }) { + const today = new Date(); + + this.state = { + step, + firstDayOfWeek, + weekends, + calViewSize, + // min & max are NaN if empty or invalid + min: new Date(Number.isNaN(min) ? MIN_DATE : min), + max: new Date(Number.isNaN(max) ? MAX_DATE : max), + stepBase: new Date(stepBase), + today: this._newUTCDate( + today.getFullYear(), + today.getMonth(), + today.getDate() + ), + weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends), + years: [], + dateObj: new Date(0), + selection: { year, month, day }, + }; + + this.setCalendarMonth({ + year: year === undefined ? today.getFullYear() : year, + month: month === undefined ? today.getMonth() : month, + }); + }, + + /** + * Set new calendar month. The year is always treated as full year, so the + * short-form is not supported. + * @param {Object} date parts + * { + * {Number} year [optional] + * {Number} month [optional] + * } + */ + setCalendarMonth({ year = this.year, month = this.month }) { + // Make sure the date is valid before setting. + // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 + if (year > MAX_YEAR || (year === MAX_YEAR && month >= MAX_MONTH)) { + this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1); + } else if (year < 1 || (year === 1 && month < 0)) { + this.state.dateObj.setUTCFullYear(1, 0, 1); + } else { + this.state.dateObj.setUTCFullYear(year, month, 1); + } + }, + + /** + * Set selection date + * @param {Number} year + * @param {Number} month + * @param {Number} day + */ + setSelection({ year, month, day }) { + this.state.selection.year = year; + this.state.selection.month = month; + this.state.selection.day = day; + }, + + /** + * Set month. Makes sure the day is <= the last day of the month + * @param {Number} month + */ + setMonth(month) { + this.setCalendarMonth({ year: this.year, month }); + }, + + /** + * Set year. Makes sure the day is <= the last day of the month + * @param {Number} year + */ + setYear(year) { + this.setCalendarMonth({ year, month: this.month }); + }, + + /** + * Set month by offset. Makes sure the day is <= the last day of the month + * @param {Number} offset + */ + setMonthByOffset(offset) { + this.setCalendarMonth({ year: this.year, month: this.month + offset }); + }, + + /** + * Generate the array of months + * @return {Array<Object>} + * { + * {Number} value: Month in int + * {Boolean} enabled + * } + */ + getMonths() { + let months = []; + + for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { + months.push({ + value: i, + enabled: true, + }); + } + + return months; + }, + + /** + * Generate the array of years + * @return {Array<Object>} + * { + * {Number} value: Year in int + * {Boolean} enabled + * } + */ + getYears() { + let years = []; + + const firstItem = this.state.years[0]; + const lastItem = this.state.years[this.state.years.length - 1]; + const currentYear = this.year; + + // Generate new years array when the year is outside of the first & + // last item range. If not, return the cached result. + if ( + !firstItem || + !lastItem || + currentYear <= firstItem.value + YEAR_BUFFER_SIZE || + currentYear >= lastItem.value - YEAR_BUFFER_SIZE + ) { + // The year is set in the middle with items on both directions + for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) { + const year = currentYear + i; + if (year >= 1 && year <= MAX_YEAR) { + years.push({ + value: year, + enabled: true, + }); + } + } + this.state.years = years; + } + return this.state.years; + }, + + /** + * Get days for calendar + * @return {Array<Object>} + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + */ + getDays() { + const firstDayOfMonth = this._getFirstCalendarDate( + this.state.dateObj, + this.state.firstDayOfWeek + ); + const month = this.month; + let days = []; + + for (let i = 0; i < this.state.calViewSize; i++) { + const dateObj = this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + firstDayOfMonth.getUTCDate() + i + ); + + let classNames = []; + let enabled = true; + + const isValid = + dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE; + if (!isValid) { + classNames.push("out-of-range"); + enabled = false; + + days.push({ + classNames, + enabled, + }); + continue; + } + + const isWeekend = this.state.weekends.includes(dateObj.getUTCDay()); + const isCurrentMonth = month == dateObj.getUTCMonth(); + const isSelection = + this.state.selection.year == dateObj.getUTCFullYear() && + this.state.selection.month == dateObj.getUTCMonth() && + this.state.selection.day == dateObj.getUTCDate(); + const isOutOfRange = + dateObj.getTime() < this.state.min.getTime() || + dateObj.getTime() > this.state.max.getTime(); + const isToday = this.state.today.getTime() == dateObj.getTime(); + const isOffStep = this._checkIsOffStep( + dateObj, + this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth(), + dateObj.getUTCDate() + 1 + ) + ); + + if (isWeekend) { + classNames.push("weekend"); + } + if (!isCurrentMonth) { + classNames.push("outside"); + } + if (isSelection && !isOutOfRange && !isOffStep) { + classNames.push("selection"); + } + if (isOutOfRange) { + classNames.push("out-of-range"); + enabled = false; + } + if (isToday) { + classNames.push("today"); + } + if (isOffStep) { + classNames.push("off-step"); + enabled = false; + } + days.push({ + dateObj, + content: dateObj.getUTCDate(), + classNames, + enabled, + }); + } + return days; + }, + + /** + * Check if a date is off step given a starting point and the next increment + * @param {Date} start + * @param {Date} next + * @return {Boolean} + */ + _checkIsOffStep(start, next) { + // If the increment is larger or equal to the step, it must not be off-step. + if (next - start >= this.state.step) { + return false; + } + // Calculate the last valid date + const lastValidStep = Math.floor( + (next - 1 - this.state.stepBase) / this.state.step + ); + const lastValidTimeInMs = + lastValidStep * this.state.step + this.state.stepBase.getTime(); + // The date is off-step if the last valid date is smaller than the start date + return lastValidTimeInMs < start.getTime(); + }, + + /** + * Get week headers for calendar + * @param {Number} firstDayOfWeek + * @param {Array<Number>} weekends + * @return {Array<Object>} + * { + * {Number} content + * {Array<String>} classNames + * } + */ + _getWeekHeaders(firstDayOfWeek, weekends) { + let headers = []; + let dayOfWeek = firstDayOfWeek; + + for (let i = 0; i < DAYS_IN_A_WEEK; i++) { + headers.push({ + content: dayOfWeek % DAYS_IN_A_WEEK, + classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) + ? ["weekend"] + : [], + }); + dayOfWeek++; + } + return headers; + }, + + /** + * Get the first day on a calendar month + * @param {Date} dateObj + * @param {Number} firstDayOfWeek + * @return {Date} + */ + _getFirstCalendarDate(dateObj, firstDayOfWeek) { + const daysOffset = 1 - DAYS_IN_A_WEEK; + let firstDayOfMonth = this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth() + ); + let dayOfWeek = firstDayOfMonth.getUTCDay(); + + return this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + // When first calendar date is the same as first day of the week, add + // another row on top of it. + firstDayOfWeek == dayOfWeek + ? daysOffset + : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK + ); + }, + + /** + * Helper function for creating UTC dates + * @param {...[Number]} parts + * @return {Date} + */ + _newUTCDate(...parts) { + return new Date(new Date(0).setUTCFullYear(...parts)); + }, + }; +} diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js new file mode 100644 index 0000000000..193e896ac8 --- /dev/null +++ b/toolkit/content/widgets/datepicker.js @@ -0,0 +1,450 @@ +/* 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/. */ + +/* import-globals-from datekeeper.js */ +/* import-globals-from calendar.js */ +/* import-globals-from spinner.js */ + +"use strict"; + +function DatePicker(context) { + this.context = context; + this._attachEventListeners(); +} + +{ + const CAL_VIEW_SIZE = 42; + + DatePicker.prototype = { + /** + * Initializes the date picker. Set the default states and properties. + * @param {Object} props + * { + * {Number} year [optional] + * {Number} month [optional] + * {Number} date [optional] + * {Number} min + * {Number} max + * {Number} step + * {Number} stepBase + * {Number} firstDayOfWeek + * {Array<Number>} weekends + * {Array<String>} monthStrings + * {Array<String>} weekdayStrings + * {String} locale [optional]: User preferred locale + * } + */ + init(props = {}) { + this.props = props; + this._setDefaultState(); + this._createComponents(); + this._update(); + document.dispatchEvent(new CustomEvent("PickerReady")); + }, + + /* + * Set initial date picker states. + */ + _setDefaultState() { + const { + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek, + weekends, + monthStrings, + weekdayStrings, + locale, + dir, + } = this.props; + const dateKeeper = new DateKeeper({ + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek, + weekends, + calViewSize: CAL_VIEW_SIZE, + }); + + document.dir = dir; + + this.state = { + dateKeeper, + locale, + isMonthPickerVisible: false, + datetimeOrders: new Intl.DateTimeFormat(locale) + .formatToParts(new Date(0)) + .map(part => part.type), + getDayString: day => + day ? new Intl.NumberFormat(locale).format(day) : "", + getWeekHeaderString: weekday => weekdayStrings[weekday], + getMonthString: month => monthStrings[month], + setSelection: date => { + dateKeeper.setSelection({ + year: date.getUTCFullYear(), + month: date.getUTCMonth(), + day: date.getUTCDate(), + }); + this._update(); + this._dispatchState(); + this._closePopup(); + }, + setYear: year => { + dateKeeper.setYear(year); + dateKeeper.setSelection({ + year, + month: dateKeeper.selection.month, + day: dateKeeper.selection.day, + }); + this._update(); + this._dispatchState(); + }, + setMonth: month => { + dateKeeper.setMonth(month); + dateKeeper.setSelection({ + year: dateKeeper.selection.year, + month, + day: dateKeeper.selection.day, + }); + this._update(); + this._dispatchState(); + }, + toggleMonthPicker: () => { + this.state.isMonthPickerVisible = !this.state.isMonthPickerVisible; + this._update(); + }, + }; + }, + + /** + * Initalize the date picker components. + */ + _createComponents() { + this.components = { + calendar: new Calendar( + { + calViewSize: CAL_VIEW_SIZE, + locale: this.state.locale, + setSelection: this.state.setSelection, + getDayString: this.state.getDayString, + getWeekHeaderString: this.state.getWeekHeaderString, + }, + { + weekHeader: this.context.weekHeader, + daysView: this.context.daysView, + } + ), + monthYear: new MonthYear( + { + setYear: this.state.setYear, + setMonth: this.state.setMonth, + getMonthString: this.state.getMonthString, + datetimeOrders: this.state.datetimeOrders, + locale: this.state.locale, + }, + { + monthYear: this.context.monthYear, + monthYearView: this.context.monthYearView, + } + ), + }; + }, + + /** + * Update date picker and its components. + */ + _update(options = {}) { + const { dateKeeper, isMonthPickerVisible } = this.state; + + if (isMonthPickerVisible) { + this.state.months = dateKeeper.getMonths(); + this.state.years = dateKeeper.getYears(); + } else { + this.state.days = dateKeeper.getDays(); + } + + this.components.monthYear.setProps({ + isVisible: isMonthPickerVisible, + dateObj: dateKeeper.state.dateObj, + months: this.state.months, + years: this.state.years, + toggleMonthPicker: this.state.toggleMonthPicker, + noSmoothScroll: options.noSmoothScroll, + }); + this.components.calendar.setProps({ + isVisible: !isMonthPickerVisible, + days: this.state.days, + weekHeaders: dateKeeper.state.weekHeaders, + }); + + isMonthPickerVisible + ? this.context.monthYearView.classList.remove("hidden") + : this.context.monthYearView.classList.add("hidden"); + }, + + /** + * Use postMessage to close the picker. + */ + _closePopup() { + window.postMessage( + { + name: "ClosePopup", + }, + "*" + ); + }, + + /** + * Use postMessage to pass the state of picker to the panel. + */ + _dispatchState() { + const { year, month, day } = this.state.dateKeeper.selection; + // The panel is listening to window for postMessage event, so we + // do postMessage to itself to send data to input boxes. + window.postMessage( + { + name: "PickerPopupChanged", + detail: { + year, + month, + day, + }, + }, + "*" + ); + }, + + /** + * Attach event listeners + */ + _attachEventListeners() { + window.addEventListener("message", this); + document.addEventListener("mouseup", this, { passive: true }); + document.addEventListener("mousedown", this); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setCapture(); + + if (event.target == this.context.buttonPrev) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(-1); + this._update(); + } else if (event.target == this.context.buttonNext) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(1); + this._update(); + } + break; + } + case "mouseup": { + if ( + event.target == this.context.buttonPrev || + event.target == this.context.buttonNext + ) { + event.target.classList.remove("active"); + } + } + } + }, + + /** + * Handle postMessage events. + * + * @param {Event} event + */ + handleMessage(event) { + switch (event.data.name) { + case "PickerSetValue": { + this.set(event.data.detail); + break; + } + case "PickerInit": { + this.init(event.data.detail); + break; + } + } + }, + + /** + * Set the date state and update the components with the new state. + * + * @param {Object} dateState + * { + * {Number} year [optional] + * {Number} month [optional] + * {Number} date [optional] + * } + */ + set({ year, month, day }) { + if (!this.state) { + return; + } + + const { dateKeeper } = this.state; + + dateKeeper.setCalendarMonth({ + year, + month, + }); + dateKeeper.setSelection({ + year, + month, + day, + }); + this._update({ noSmoothScroll: true }); + }, + }; + + /** + * MonthYear is a component that handles the month & year spinners + * + * @param {Object} options + * { + * {String} locale + * {Function} setYear + * {Function} setMonth + * {Function} getMonthString + * {Array<String>} datetimeOrders + * } + * @param {DOMElement} context + */ + function MonthYear(options, context) { + const spinnerSize = 5; + const yearFormat = new Intl.DateTimeFormat(options.locale, { + year: "numeric", + timeZone: "UTC", + }).format; + const dateFormat = new Intl.DateTimeFormat(options.locale, { + year: "numeric", + month: "long", + timeZone: "UTC", + }).format; + const spinnerOrder = + options.datetimeOrders.indexOf("month") < + options.datetimeOrders.indexOf("year") + ? "order-month-year" + : "order-year-month"; + + context.monthYearView.classList.add(spinnerOrder); + + this.context = context; + this.state = { dateFormat }; + this.props = {}; + this.components = { + month: new Spinner( + { + id: "spinner-month", + setValue: month => { + this.state.isMonthSet = true; + options.setMonth(month); + }, + getDisplayString: options.getMonthString, + viewportSize: spinnerSize, + }, + context.monthYearView + ), + year: new Spinner( + { + id: "spinner-year", + setValue: year => { + this.state.isYearSet = true; + options.setYear(year); + }, + getDisplayString: year => + yearFormat(new Date(new Date(0).setUTCFullYear(year))), + viewportSize: spinnerSize, + }, + context.monthYearView + ), + }; + + this._attachEventListeners(); + } + + MonthYear.prototype = { + /** + * Set new properties and pass them to components + * + * @param {Object} props + * { + * {Boolean} isVisible + * {Date} dateObj + * {Array<Object>} months + * {Array<Object>} years + * {Function} toggleMonthPicker + * } + */ + setProps(props) { + this.context.monthYear.textContent = this.state.dateFormat(props.dateObj); + + if (props.isVisible) { + this.context.monthYear.classList.add("active"); + this.components.month.setState({ + value: props.dateObj.getUTCMonth(), + items: props.months, + isInfiniteScroll: true, + isValueSet: this.state.isMonthSet, + smoothScroll: !(this.state.firstOpened || props.noSmoothScroll), + }); + this.components.year.setState({ + value: props.dateObj.getUTCFullYear(), + items: props.years, + isInfiniteScroll: false, + isValueSet: this.state.isYearSet, + smoothScroll: !(this.state.firstOpened || props.noSmoothScroll), + }); + this.state.firstOpened = false; + } else { + this.context.monthYear.classList.remove("active"); + this.state.isMonthSet = false; + this.state.isYearSet = false; + this.state.firstOpened = true; + } + + this.props = Object.assign(this.props, props); + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + this.props.toggleMonthPicker(); + break; + } + } + }, + + /** + * Attach event listener to monthYear button + */ + _attachEventListeners() { + this.context.monthYear.addEventListener("click", this); + }, + }; +} diff --git a/toolkit/content/widgets/datetimebox.css b/toolkit/content/widgets/datetimebox.css new file mode 100644 index 0000000000..8b694f2efc --- /dev/null +++ b/toolkit/content/widgets/datetimebox.css @@ -0,0 +1,62 @@ +/* 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/. */ + +@namespace url("http://www.w3.org/1999/xhtml"); +@namespace svg url("http://www.w3.org/2000/svg"); + +.datetimebox { + display: flex; + /* TODO: Enable selection once bug 1455893 is fixed */ + user-select: none; +} + +.datetime-input-box-wrapper { + display: inline-flex; + flex: 1; + background-color: inherit; + min-width: 0; + justify-content: space-between; + align-items: center; +} + +.datetime-input-edit-wrapper { + overflow: hidden; + white-space: nowrap; + flex-grow: 1; +} + +.datetime-edit-field { + display: inline; + text-align: center; + padding: 1px 3px; + border: 0; + margin: 0; + ime-mode: disabled; + outline: none; +} + +.datetime-edit-field:not([disabled="true"]):focus { + background-color: Highlight; + color: HighlightText; + outline: none; +} + +.datetime-edit-field[disabled="true"], +.datetime-edit-field[readonly="true"] { + user-select: none; +} + +.datetime-reset-button { + color: inherit; + fill: currentColor; + opacity: .5; + background-color: transparent; + border: none; + flex: none; + padding-inline: 2px; +} + +svg|svg.datetime-reset-button-svg { + pointer-events: none; +} diff --git a/toolkit/content/widgets/datetimebox.js b/toolkit/content/widgets/datetimebox.js new file mode 100644 index 0000000000..db7127f11d --- /dev/null +++ b/toolkit/content/widgets/datetimebox.js @@ -0,0 +1,1760 @@ +/* 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 is a UA widget. It runs in per-origin UA widget scope, +// to be loaded by UAWidgetsChild.jsm. + +/* + * This is the class of entry. It will construct the actual implementation + * according to the value of the "type" property. + */ +this.DateTimeBoxWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgets when the "type" property changes. + */ + onchange() { + this.switchImpl(); + } + + /* + * Actually switch the implementation. + * - With type equal to "date", DateInputImplWidget should load. + * - With type equal to "time", TimeInputImplWidget should load. + * - Otherwise, nothing should load and loaded impl should be unloaded. + */ + switchImpl() { + let newImpl; + if (this.element.type == "date") { + newImpl = DateInputImplWidget; + } else if (this.element.type == "time") { + newImpl = TimeInputImplWidget; + } + // Skip if we are asked to load the same implementation. + // This can happen if the property is set again w/o value change. + if (this.impl && this.impl.constructor == newImpl) { + return; + } + if (this.impl) { + this.impl.destructor(); + this.shadowRoot.firstChild.remove(); + } + if (newImpl) { + this.impl = new newImpl(this.shadowRoot); + this.impl.onsetup(); + } else { + this.impl = undefined; + } + } + + destructor() { + if (!this.impl) { + return; + } + this.impl.destructor(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } +}; + +this.DateTimeInputBaseImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup() { + this.generateContent(); + + this.DEBUG = false; + this.mDateTimeBoxElement = this.shadowRoot.firstChild; + this.mInputElement = this.element; + this.mLocales = this.window.getWebExposedLocales(); + + this.mIsRTL = false; + let intlUtils = this.window.intlUtils; + if (intlUtils) { + this.mIsRTL = intlUtils.isAppLocaleRTL(); + } + + if (this.mIsRTL) { + let inputBoxWrapper = this.shadowRoot.getElementById("input-box-wrapper"); + inputBoxWrapper.dir = "rtl"; + } + + this.mMin = this.mInputElement.min; + this.mMax = this.mInputElement.max; + this.mStep = this.mInputElement.step; + this.mIsPickerOpen = false; + + this.mResetButton = this.shadowRoot.getElementById("reset-button"); + this.mResetButton.style.visibility = "hidden"; + this.mResetButton.addEventListener("mousedown", this, { + mozSystemGroup: true, + }); + + this.mInputElement.addEventListener( + "keypress", + this, + { + capture: true, + mozSystemGroup: true, + }, + false + ); + // This is to open the picker when input element is clicked (this + // includes padding area). + this.mInputElement.addEventListener( + "click", + this, + { mozSystemGroup: true }, + false + ); + + // Those events are dispatched to <div class="datetimebox"> with bubble set + // to false. They are trapped inside UA Widget Shadow DOM and are not + // dispatched to the document. + this.CONTROL_EVENTS.forEach(eventName => { + this.mDateTimeBoxElement.addEventListener(eventName, this, {}, false); + }); + } + + generateContent() { + /* + * Pass the markup through XML parser purely for the reason of loading the localization DTD. + * Remove it when migrate to Fluent (bug 1504363). + */ + const parser = new this.window.DOMParser(); + parser.forceEnableDTD(); + let parserDoc = parser.parseFromString( + `<!DOCTYPE bindings [ + <!ENTITY % datetimeboxDTD SYSTEM "chrome://global/locale/datetimebox.dtd"> + %datetimeboxDTD; + ]> + <div class="datetimebox" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" type="text/css" href="chrome://global/content/bindings/datetimebox.css" /> + <div class="datetime-input-box-wrapper" id="input-box-wrapper" role="presentation"> + <span class="datetime-input-edit-wrapper" + id="edit-wrapper"> + <!-- Each of the date/time input types will append their input child + - elements here --> + </span> + + <button class="datetime-reset-button" id="reset-button" tabindex="-1" aria-label="&datetime.reset.label;"> + <svg xmlns="http://www.w3.org/2000/svg" class="datetime-reset-button-svg" width="12" height="12"> + <path d="M 3.9,3 3,3.9 5.1,6 3,8.1 3.9,9 6,6.9 8.1,9 9,8.1 6.9,6 9,3.9 8.1,3 6,5.1 Z M 12,6 A 6,6 0 0 1 6,12 6,6 0 0 1 0,6 6,6 0 0 1 6,0 6,6 0 0 1 12,6 Z"/> + </svg> + </button> + </div> + <div id="strings" + data-m-year-place-holder="&date.year.placeholder;" + data-m-year-label="&date.year.label;" + data-m-month-place-holder="&date.month.placeholder;" + data-m-month-label="&date.month.label;" + data-m-day-place-holder="&date.day.placeholder;" + data-m-day-label="&date.day.label;" + + data-m-hour-place-holder="&time.hour.placeholder;" + data-m-hour-label="&time.hour.label;" + data-m-minute-place-holder="&time.minute.placeholder;" + data-m-minute-label="&time.minute.label;" + data-m-second-place-holder="&time.second.placeholder;" + data-m-second-label="&time.second.label;" + data-m-millisecond-place-holder="&time.millisecond.placeholder;" + data-m-millisecond-label="&time.millisecond.label;" + data-m-day-period-place-holder="&time.dayperiod.placeholder;" + data-m-day-period-label="&time.dayperiod.label;" + ></div> + </div>`, + "application/xml" + ); + + /* + * The <div id="strings"> is also parsed in the document so that there is no + * need to create another XML document just to get the strings. + */ + let stringsElement = parserDoc.getElementById("strings"); + stringsElement.remove(); + for (let key in stringsElement.dataset) { + // key will be camelCase version of the attribute key above, + // like mYearPlaceHolder. + this[key] = stringsElement.dataset[key]; + } + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } + + destructor() { + this.mResetButton.addEventListener("mousedown", this, { + mozSystemGroup: true, + }); + + this.mInputElement.removeEventListener("keypress", this, { + capture: true, + mozSystemGroup: true, + }); + this.mInputElement.removeEventListener("click", this, { + mozSystemGroup: true, + }); + + this.CONTROL_EVENTS.forEach(eventName => { + this.mDateTimeBoxElement.removeEventListener(eventName, this); + }); + this.mInputElement = null; + } + + get FIELD_EVENTS() { + return ["focus", "blur", "copy", "cut", "paste"]; + } + + get CONTROL_EVENTS() { + return [ + "MozDateTimeValueChanged", + "MozNotifyMinMaxStepAttrChanged", + "MozFocusInnerTextBox", + "MozBlurInnerTextBox", + "MozDateTimeAttributeChanged", + "MozPickerValueChanged", + "MozSetDateTimePickerState", + ]; + } + + addEventListenersToField(aElement) { + // These events don't bubble out of the Shadow DOM, so we'll have to add + // event listeners specifically on each of the fields, not just + // on the <input> + this.FIELD_EVENTS.forEach(eventName => { + aElement.addEventListener( + eventName, + this, + { mozSystemGroup: true }, + false + ); + }); + } + + removeEventListenersToField(aElement) { + if (!aElement) { + return; + } + + this.FIELD_EVENTS.forEach(eventName => { + aElement.removeEventListener(eventName, this, { mozSystemGroup: true }); + }); + } + + log(aMsg) { + if (this.DEBUG) { + this.window.dump("[DateTimeBox] " + aMsg + "\n"); + } + } + + createEditFieldAndAppend( + aPlaceHolder, + aLabel, + aIsNumeric, + aMinDigits, + aMaxLength, + aMinValue, + aMaxValue, + aPageUpDownInterval + ) { + let root = this.shadowRoot.getElementById("edit-wrapper"); + let field = this.shadowRoot.createElementAndAppendChildAt(root, "span"); + field.classList.add("datetime-edit-field"); + field.textContent = aPlaceHolder; + field.placeholder = aPlaceHolder; + field.tabIndex = this.mInputElement.tabIndex; + + field.setAttribute("readonly", this.mInputElement.readOnly); + field.setAttribute("disabled", this.mInputElement.disabled); + // Set property as well for convenience. + field.disabled = this.mInputElement.disabled; + field.readOnly = this.mInputElement.readOnly; + field.setAttribute("aria-label", aLabel); + + // Used to store the non-formatted value, cleared when value is + // cleared. + // DateTimeInputTypeBase::HasBadInput() will read this to decide + // if the input has value. + field.setAttribute("value", ""); + + if (aIsNumeric) { + field.classList.add("numeric"); + // Maximum value allowed. + field.setAttribute("min", aMinValue); + // Minumim value allowed. + field.setAttribute("max", aMaxValue); + // Interval when pressing pageUp/pageDown key. + field.setAttribute("pginterval", aPageUpDownInterval); + // Used to store what the user has already typed in the field, + // cleared when value is cleared and when field is blurred. + field.setAttribute("typeBuffer", ""); + // Minimum digits to display, padded with leading 0s. + field.setAttribute("mindigits", aMinDigits); + // Maximum length for the field, will be advance to the next field + // automatically if exceeded. + field.setAttribute("maxlength", aMaxLength); + // Set spinbutton ARIA role + field.setAttribute("role", "spinbutton"); + + if (this.mIsRTL) { + // Force the direction to be "ltr", so that the field stays in the + // same order even when it's empty (with placeholder). By using + // "embed", the text inside the element is still displayed based + // on its directionality. + field.style.unicodeBidi = "embed"; + field.style.direction = "ltr"; + } + } else { + // Set generic textbox ARIA role + field.setAttribute("role", "textbox"); + } + + return field; + } + + updateResetButtonVisibility() { + if (this.isAnyFieldAvailable(false) && !this.isRequired()) { + this.mResetButton.style.visibility = ""; + } else { + this.mResetButton.style.visibility = "hidden"; + } + } + + focusInnerTextBox() { + this.log("Focus inner editable field."); + + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + this.mLastFocusedField = child; + child.focus(); + this.log("focused"); + break; + } + } + + blurInnerTextBox() { + this.log("Blur inner editable field."); + + if (this.mLastFocusedField) { + this.mLastFocusedField.blur(); + } else { + // If .mLastFocusedField hasn't been set, blur all editable fields, + // so that the bound element will actually be blurred. Note that + // blurring on a element that has no focus won't have any effect. + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + child.blur(); + } + } + } + + notifyInputElementValueChanged() { + this.log("inputElementValueChanged"); + this.setFieldsFromInputValue(); + } + + notifyMinMaxStepAttrChanged() { + // No operation by default + } + + setValueFromPicker(aValue) { + this.setFieldsFromPicker(aValue); + } + + advanceToNextField(aReverse) { + this.log("advanceToNextField"); + + let focusedInput = this.mLastFocusedField; + let next = aReverse + ? focusedInput.previousElementSibling + : focusedInput.nextElementSibling; + if (!next && !aReverse) { + this.setInputValueFromFields(); + return; + } + + while (next) { + if (next.matches("span.datetime-edit-field")) { + next.focus(); + break; + } + next = aReverse ? next.previousElementSibling : next.nextElementSibling; + } + } + + setPickerState(aIsOpen) { + this.log("picker is now " + (aIsOpen ? "opened" : "closed")); + this.mIsPickerOpen = aIsOpen; + } + + updateEditAttributes() { + this.log("updateEditAttributes"); + + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + // "disabled" and "readonly" must be set as attributes because they + // are not defined properties of HTMLSpanElement, and the stylesheet + // checks the literal string attribute values. + child.setAttribute("disabled", this.mInputElement.disabled); + child.setAttribute("readonly", this.mInputElement.readOnly); + + // Set property as well for convenience. + child.disabled = this.mInputElement.disabled; + child.readOnly = this.mInputElement.readOnly; + + // tabIndex works on all elements + child.tabIndex = this.mInputElement.tabIndex; + } + + this.mResetButton.disabled = + this.mInputElement.disabled || this.mInputElement.readOnly; + this.updateResetButtonVisibility(); + } + + isEmpty(aValue) { + return aValue == undefined || 0 === aValue.length; + } + + getFieldValue(aField) { + if (!aField || !aField.classList.contains("numeric")) { + return undefined; + } + + let value = aField.getAttribute("value"); + // Avoid returning 0 when field is empty. + return this.isEmpty(value) ? undefined : Number(value); + } + + clearFieldValue(aField) { + aField.textContent = aField.placeholder; + aField.setAttribute("value", ""); + if (aField.classList.contains("numeric")) { + aField.setAttribute("typeBuffer", ""); + } + this.updateResetButtonVisibility(); + } + + setFieldValue() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + clearInputFields() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + setFieldsFromInputValue() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + setInputValueFromFields() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + setFieldsFromPicker() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + handleKeypress() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + handleKeyboardNav() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + getCurrentValue() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + isAnyFieldAvailable() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + notifyPicker() { + if (this.mIsPickerOpen && this.isAnyFieldAvailable(true)) { + this.mInputElement.updateDateTimePicker(this.getCurrentValue()); + } + } + + isDisabled() { + return this.mInputElement.hasAttribute("disabled"); + } + + isReadonly() { + return this.mInputElement.hasAttribute("readonly"); + } + + isEditable() { + return !this.isDisabled() && !this.isReadonly(); + } + + isRequired() { + return this.mInputElement.hasAttribute("required"); + } + + containingTree() { + return this.mInputElement.containingShadowRoot || this.document; + } + + handleEvent(aEvent) { + this.log("handleEvent: " + aEvent.type); + + if (!aEvent.isTrusted) { + return; + } + + switch (aEvent.type) { + case "MozDateTimeValueChanged": { + this.notifyInputElementValueChanged(); + break; + } + case "MozNotifyMinMaxStepAttrChanged": { + this.notifyMinMaxStepAttrChanged(); + break; + } + case "MozFocusInnerTextBox": { + this.focusInnerTextBox(); + break; + } + case "MozBlurInnerTextBox": { + this.blurInnerTextBox(); + break; + } + case "MozDateTimeAttributeChanged": { + this.updateEditAttributes(); + break; + } + case "MozPickerValueChanged": { + this.setValueFromPicker(aEvent.detail); + break; + } + case "MozSetDateTimePickerState": { + this.setPickerState(aEvent.detail); + break; + } + case "keypress": { + this.onKeyPress(aEvent); + break; + } + case "click": { + this.onClick(aEvent); + break; + } + case "focus": { + this.onFocus(aEvent); + break; + } + case "blur": { + this.onBlur(aEvent); + break; + } + case "mousedown": + case "copy": + case "cut": + case "paste": { + aEvent.preventDefault(); + break; + } + default: + break; + } + } + + onFocus(aEvent) { + this.log("onFocus originalTarget: " + aEvent.originalTarget); + if (this.containingTree().activeElement != this.mInputElement) { + return; + } + + let target = aEvent.originalTarget; + if (target.matches("span.datetime-edit-field")) { + if (target.disabled) { + return; + } + this.mLastFocusedField = target; + this.mInputElement.setFocusState(true); + } + } + + onBlur(aEvent) { + this.log( + "onBlur originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + + " rt: " + + aEvent.relatedTarget + ); + + let target = aEvent.originalTarget; + target.setAttribute("typeBuffer", ""); + this.setInputValueFromFields(); + // No need to set and unset the focus state if the focus is staying within + // our input. Same about closing the picker. + if (aEvent.relatedTarget != this.mInputElement) { + this.mInputElement.setFocusState(false); + if (this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + } + } + } + + onKeyPress(aEvent) { + this.log("onKeyPress key: " + aEvent.key); + + switch (aEvent.key) { + // Toggle the picker on space/enter, close on Escape. + case "Enter": + case "Escape": + case " ": { + if (this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + } else if (aEvent.key != "Escape") { + this.mInputElement.openDateTimePicker(this.getCurrentValue()); + } else { + // Don't preventDefault(); + break; + } + aEvent.preventDefault(); + break; + } + case "Backspace": { + // TODO(emilio, bug 1571533): These functions should look at + // defaultPrevented. + if (this.isEditable()) { + let targetField = aEvent.originalTarget; + this.clearFieldValue(targetField); + this.setInputValueFromFields(); + aEvent.preventDefault(); + } + break; + } + case "ArrowRight": + case "ArrowLeft": { + this.advanceToNextField(!(aEvent.key == "ArrowRight")); + aEvent.preventDefault(); + break; + } + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + case "Home": + case "End": { + this.handleKeyboardNav(aEvent); + aEvent.preventDefault(); + break; + } + default: { + // printable characters + if ( + aEvent.keyCode == 0 && + !(aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey) + ) { + this.handleKeypress(aEvent); + aEvent.preventDefault(); + } + break; + } + } + } + + onClick(aEvent) { + this.log( + "onClick originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + ); + + if (aEvent.defaultPrevented || !this.isEditable()) { + return; + } + + if (aEvent.originalTarget == this.mResetButton) { + this.clearInputFields(false); + } else if (!this.mIsPickerOpen) { + this.mInputElement.openDateTimePicker(this.getCurrentValue()); + } + } +}; + +this.DateInputImplWidget = class extends DateTimeInputBaseImplWidget { + constructor(shadowRoot) { + super(shadowRoot); + } + + onsetup() { + super.onsetup(); + + this.mMinMonth = 1; + this.mMaxMonth = 12; + this.mMinDay = 1; + this.mMaxDay = 31; + this.mMinYear = 1; + // Maximum year limited by ECMAScript date object range, year <= 275760. + this.mMaxYear = 275760; + this.mMonthDayLength = 2; + this.mYearLength = 4; + this.mMonthPageUpDownInterval = 3; + this.mDayPageUpDownInterval = 7; + this.mYearPageUpDownInterval = 10; + + this.buildEditFields(); + this.updateEditAttributes(); + + if (this.mInputElement.value) { + this.setFieldsFromInputValue(); + } + } + + destructor() { + this.removeEventListenersToField(this.mYearField); + this.removeEventListenersToField(this.mMonthField); + this.removeEventListenersToField(this.mDayField); + super.destructor(); + } + + buildEditFields() { + let root = this.shadowRoot.getElementById("edit-wrapper"); + + let yearMaxLength = this.mMaxYear.toString().length; + + let formatter = Intl.DateTimeFormat(this.mLocales, { + year: "numeric", + month: "numeric", + day: "numeric", + }); + formatter.formatToParts(Date.now()).map(part => { + switch (part.type) { + case "year": + this.mYearField = this.createEditFieldAndAppend( + this.mYearPlaceHolder, + this.mYearLabel, + true, + this.mYearLength, + yearMaxLength, + this.mMinYear, + this.mMaxYear, + this.mYearPageUpDownInterval + ); + this.addEventListenersToField(this.mYearField); + break; + case "month": + this.mMonthField = this.createEditFieldAndAppend( + this.mMonthPlaceHolder, + this.mMonthLabel, + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinMonth, + this.mMaxMonth, + this.mMonthPageUpDownInterval + ); + this.addEventListenersToField(this.mMonthField); + break; + case "day": + this.mDayField = this.createEditFieldAndAppend( + this.mDayPlaceHolder, + this.mDayLabel, + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinDay, + this.mMaxDay, + this.mDayPageUpDownInterval + ); + this.addEventListenersToField(this.mDayField); + break; + default: + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = part.value; + break; + } + }); + } + + clearInputFields(aFromInputElement) { + this.log("clearInputFields"); + + if (this.mMonthField) { + this.clearFieldValue(this.mMonthField); + } + + if (this.mDayField) { + this.clearFieldValue(this.mDayField); + } + + if (this.mYearField) { + this.clearFieldValue(this.mYearField); + } + + if (!aFromInputElement) { + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + } + } + + setFieldsFromInputValue() { + let value = this.mInputElement.value; + if (!value) { + this.clearInputFields(true); + return; + } + + this.log("setFieldsFromInputValue: " + value); + let [year, month, day] = value.split("-"); + + this.setFieldValue(this.mYearField, year); + this.setFieldValue(this.mMonthField, month); + this.setFieldValue(this.mDayField, day); + + this.notifyPicker(); + } + + setInputValueFromFields() { + if (this.isAnyFieldEmpty()) { + // Clear input element's value if any of the field has been cleared, + // otherwise update the validity state, since it may become "not" + // invalid if fields are not complete. + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + // We still need to notify picker in case any of the field has + // changed. + this.notifyPicker(); + return; + } + + let { year, month, day } = this.getCurrentValue(); + + // Convert to a valid date string according to: + // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-date-string + year = year.toString().padStart(this.mYearLength, "0"); + month = month < 10 ? "0" + month : month; + day = day < 10 ? "0" + day : day; + + let date = [year, month, day].join("-"); + + if (date == this.mInputElement.value) { + return; + } + + this.log("setInputValueFromFields: " + date); + this.notifyPicker(); + this.mInputElement.setUserInput(date); + } + + setFieldsFromPicker(aValue) { + let year = aValue.year; + let month = aValue.month; + let day = aValue.day; + + if (!this.isEmpty(year)) { + this.setFieldValue(this.mYearField, year); + } + + if (!this.isEmpty(month)) { + this.setFieldValue(this.mMonthField, month); + } + + if (!this.isEmpty(day)) { + this.setFieldValue(this.mDayField, day); + } + + // Update input element's .value if needed. + this.setInputValueFromFields(); + } + + handleKeypress(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) { + let buffer = targetField.getAttribute("typeBuffer") || ""; + + buffer = buffer.concat(key); + this.setFieldValue(targetField, buffer); + + let n = Number(buffer); + let max = targetField.getAttribute("max"); + let maxLength = targetField.getAttribute("maxlength"); + if (buffer.length >= maxLength || n * 10 > max) { + buffer = ""; + this.advanceToNextField(); + } + targetField.setAttribute("typeBuffer", buffer); + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + } + } + + incrementFieldValue(aTargetField, aTimes) { + let value = this.getFieldValue(aTargetField); + + // Use current date if field is empty. + if (this.isEmpty(value)) { + let now = new Date(); + + if (aTargetField == this.mYearField) { + value = now.getFullYear(); + } else if (aTargetField == this.mMonthField) { + value = now.getMonth() + 1; + } else if (aTargetField == this.mDayField) { + value = now.getDate(); + } else { + this.log("Field not supported in incrementFieldValue."); + return; + } + } + + let min = Number(aTargetField.getAttribute("min")); + let max = Number(aTargetField.getAttribute("max")); + + value += Number(aTimes); + if (value > max) { + value -= max - min + 1; + } else if (value < min) { + value += max - min + 1; + } + + this.setFieldValue(aTargetField, value); + } + + handleKeyboardNav(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + // Home/End key does nothing on year field. + if (targetField == this.mYearField && (key == "Home" || key == "End")) { + return; + } + + switch (key) { + case "ArrowUp": + this.incrementFieldValue(targetField, 1); + break; + case "ArrowDown": + this.incrementFieldValue(targetField, -1); + break; + case "PageUp": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, interval); + break; + } + case "PageDown": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, 0 - interval); + break; + } + case "Home": + let min = targetField.getAttribute("min"); + this.setFieldValue(targetField, min); + break; + case "End": + let max = targetField.getAttribute("max"); + this.setFieldValue(targetField, max); + break; + } + this.setInputValueFromFields(); + } + + getCurrentValue() { + let year = this.getFieldValue(this.mYearField); + let month = this.getFieldValue(this.mMonthField); + let day = this.getFieldValue(this.mDayField); + + let date = { year, month, day }; + + this.log("getCurrentValue: " + JSON.stringify(date)); + return date; + } + + setFieldValue(aField, aValue) { + if (!aField || !aField.classList.contains("numeric")) { + return; + } + + let value = Number(aValue); + if (isNaN(value)) { + this.log("NaN on setFieldValue!"); + return; + } + + let maxLength = aField.getAttribute("maxlength"); + if (aValue.length == maxLength) { + let min = Number(aField.getAttribute("min")); + let max = Number(aField.getAttribute("max")); + + if (value < min) { + value = min; + } else if (value > max) { + value = max; + } + } + + aField.setAttribute("value", value); + + // Display formatted value based on locale. + let minDigits = aField.getAttribute("mindigits"); + let formatted = value.toLocaleString(this.mLocales, { + minimumIntegerDigits: minDigits, + useGrouping: false, + }); + + aField.textContent = formatted; + aField.setAttribute("aria-valuetext", formatted); + this.updateResetButtonVisibility(); + } + + isAnyFieldAvailable(aForPicker) { + let { year, month, day } = this.getCurrentValue(); + + return !this.isEmpty(year) || !this.isEmpty(month) || !this.isEmpty(day); + } + + isAnyFieldEmpty() { + let { year, month, day } = this.getCurrentValue(); + + return this.isEmpty(year) || this.isEmpty(month) || this.isEmpty(day); + } +}; + +this.TimeInputImplWidget = class extends DateTimeInputBaseImplWidget { + constructor(shadowRoot) { + super(shadowRoot); + } + + onsetup() { + super.onsetup(); + + const kDefaultAMString = "AM"; + const kDefaultPMString = "PM"; + + let { amString, pmString } = this.getStringsForLocale(this.mLocales); + + this.mAMIndicator = amString || kDefaultAMString; + this.mPMIndicator = pmString || kDefaultPMString; + + this.mHour12 = this.is12HourTime(this.mLocales); + this.mMillisecSeparatorText = "."; + this.mMaxLength = 2; + this.mMillisecMaxLength = 3; + this.mDefaultStep = 60 * 1000; // in milliseconds + + this.mMinHour = this.mHour12 ? 1 : 0; + this.mMaxHour = this.mHour12 ? 12 : 23; + this.mMinMinute = 0; + this.mMaxMinute = 59; + this.mMinSecond = 0; + this.mMaxSecond = 59; + this.mMinMillisecond = 0; + this.mMaxMillisecond = 999; + + this.mHourPageUpDownInterval = 3; + this.mMinSecPageUpDownInterval = 10; + + this.buildEditFields(); + this.updateEditAttributes(); + + if (this.mInputElement.value) { + this.setFieldsFromInputValue(); + } + } + + destructor() { + this.removeEventListenersToField(this.mHourField); + this.removeEventListenersToField(this.mMinuteField); + this.removeEventListenersToField(this.mSecondField); + this.removeEventListenersToField(this.mMillisecField); + this.removeEventListenersToField(this.mDayPeriodField); + super.destructor(); + } + + get kMsPerSecond() { + return 1000; + } + + get kMsPerMinute() { + return 60 * 1000; + } + + getInputElementValues() { + let value = this.mInputElement.value; + if (value.length === 0) { + return {}; + } + + let hour, minute, second, millisecond; + [hour, minute, second] = value.split(":"); + if (second) { + [second, millisecond] = second.split("."); + + // Convert fraction of second to milliseconds. + if (millisecond && millisecond.length === 1) { + millisecond *= 100; + } else if (millisecond && millisecond.length === 2) { + millisecond *= 10; + } + } + + return { hour, minute, second, millisecond }; + } + + hasSecondField() { + return !!this.mSecondField; + } + + hasMillisecField() { + return !!this.mMillisecField; + } + + hasDayPeriodField() { + return !!this.mDayPeriodField; + } + + shouldShowSecondField() { + let { second } = this.getInputElementValues(); + if (second != undefined) { + return true; + } + + let stepBase = this.mInputElement.getStepBase(); + if (stepBase % this.kMsPerMinute != 0) { + return true; + } + + let step = this.mInputElement.getStep(); + if (step % this.kMsPerMinute != 0) { + return true; + } + + return false; + } + + shouldShowMillisecField() { + let { millisecond } = this.getInputElementValues(); + if (millisecond != undefined) { + return true; + } + + let stepBase = this.mInputElement.getStepBase(); + if (stepBase % this.kMsPerSecond != 0) { + return true; + } + + let step = this.mInputElement.getStep(); + if (step % this.kMsPerSecond != 0) { + return true; + } + + return false; + } + + rebuildEditFieldsIfNeeded() { + if ( + this.shouldShowSecondField() == this.hasSecondField() && + this.shouldShowMillisecField() == this.hasMillisecField() + ) { + return; + } + + let focused = this.mInputElement.matches(":focus"); + + let root = this.shadowRoot.getElementById("edit-wrapper"); + while (root.firstChild) { + root.firstChild.remove(); + } + + this.removeEventListenersToField(this.mHourField); + this.removeEventListenersToField(this.mMinuteField); + this.removeEventListenersToField(this.mSecondField); + this.removeEventListenersToField(this.mMillisecField); + this.removeEventListenersToField(this.mDayPeriodField); + + this.mHourField = null; + this.mMinuteField = null; + this.mSecondField = null; + this.mMillisecField = null; + this.mDayPeriodField = null; + + this.buildEditFields(); + if (focused) { + this.focusInnerTextBox(); + } + } + + buildEditFields() { + let root = this.shadowRoot.getElementById("edit-wrapper"); + + let options = { + hour: "numeric", + minute: "numeric", + hour12: this.mHour12, + }; + + if (this.shouldShowSecondField()) { + options.second = "numeric"; + } + + let formatter = Intl.DateTimeFormat(this.mLocales, options); + formatter.formatToParts(Date.now()).map(part => { + switch (part.type) { + case "hour": + this.mHourField = this.createEditFieldAndAppend( + this.mHourPlaceHolder, + this.mHourLabel, + true, + this.mMaxLength, + this.mMaxLength, + this.mMinHour, + this.mMaxHour, + this.mHourPageUpDownInterval + ); + this.addEventListenersToField(this.mHourField); + break; + case "minute": + this.mMinuteField = this.createEditFieldAndAppend( + this.mMinutePlaceHolder, + this.mMinuteLabel, + true, + this.mMaxLength, + this.mMaxLength, + this.mMinMinute, + this.mMaxMinute, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMinuteField); + break; + case "second": + this.mSecondField = this.createEditFieldAndAppend( + this.mSecondPlaceHolder, + this.mSecondLabel, + true, + this.mMaxLength, + this.mMaxLength, + this.mMinSecond, + this.mMaxSecond, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mSecondField); + if (this.shouldShowMillisecField()) { + // Intl.DateTimeFormat does not support millisecond, so we + // need to handle this on our own. + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = this.mMillisecSeparatorText; + this.mMillisecField = this.createEditFieldAndAppend( + this.mMillisecPlaceHolder, + this.mMillisecLabel, + true, + this.mMillisecMaxLength, + this.mMillisecMaxLength, + this.mMinMillisecond, + this.mMaxMillisecond, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMillisecField); + } + break; + case "dayPeriod": + this.mDayPeriodField = this.createEditFieldAndAppend( + this.mDayPeriodPlaceHolder, + this.mDayPeriodLabel, + false + ); + this.addEventListenersToField(this.mDayPeriodField); + + // Give aria autocomplete hint for am/pm + this.mDayPeriodField.setAttribute("aria-autocomplete", "inline"); + break; + default: + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = part.value; + break; + } + }); + } + + getStringsForLocale(aLocales) { + this.log("getStringsForLocale: " + aLocales); + + let intlUtils = this.window.intlUtils; + if (!intlUtils) { + return {}; + } + + let amString, pmString; + let keys = [ + "dates/gregorian/dayperiods/am", + "dates/gregorian/dayperiods/pm", + ]; + + let result = intlUtils.getDisplayNames(this.mLocales, { + style: "short", + keys, + }); + + [amString, pmString] = keys.map(key => result.values[key]); + + return { amString, pmString }; + } + + is12HourTime(aLocales) { + let options = new Intl.DateTimeFormat(aLocales, { + hour: "numeric", + }).resolvedOptions(); + + return options.hour12; + } + + setFieldsFromInputValue() { + let { hour, minute, second, millisecond } = this.getInputElementValues(); + + if (this.isEmpty(hour) && this.isEmpty(minute)) { + this.clearInputFields(true); + return; + } + + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + + this.setFieldValue(this.mHourField, hour); + this.setFieldValue(this.mMinuteField, minute); + if (this.mHour12) { + this.setDayPeriodValue( + hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator + ); + } + + if (this.hasSecondField()) { + this.setFieldValue(this.mSecondField, second != undefined ? second : 0); + } + + if (this.hasMillisecField()) { + this.setFieldValue( + this.mMillisecField, + millisecond != undefined ? millisecond : 0 + ); + } + + this.notifyPicker(); + } + + setInputValueFromFields() { + if (this.isAnyFieldEmpty()) { + // Clear input element's value if any of the field has been cleared, + // otherwise update the validity state, since it may become "not" + // invalid if fields are not complete. + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + // We still need to notify picker in case any of the field has + // changed. + this.notifyPicker(); + return; + } + + let { hour, minute, second, millisecond } = this.getCurrentValue(); + let dayPeriod = this.getDayPeriodValue(); + + // Convert to a valid time string according to: + // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-time-string + if (this.mHour12) { + if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { + hour += this.mMaxHour; + } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { + hour = 0; + } + } + + hour = hour < 10 ? "0" + hour : hour; + minute = minute < 10 ? "0" + minute : minute; + + let time = hour + ":" + minute; + if (second != undefined) { + second = second < 10 ? "0" + second : second; + time += ":" + second; + } + + if (millisecond != undefined) { + // Convert milliseconds to fraction of second. + millisecond = millisecond + .toString() + .padStart(this.mMillisecMaxLength, "0"); + time += "." + millisecond; + } + + if (time == this.mInputElement.value) { + return; + } + + this.log("setInputValueFromFields: " + time); + this.notifyPicker(); + this.mInputElement.setUserInput(time); + } + + setFieldsFromPicker(aValue) { + let hour = aValue.hour; + let minute = aValue.minute; + this.log("setFieldsFromPicker: " + hour + ":" + minute); + + if (!this.isEmpty(hour)) { + this.setFieldValue(this.mHourField, hour); + if (this.mHour12) { + this.setDayPeriodValue( + hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator + ); + } + } + + if (!this.isEmpty(minute)) { + this.setFieldValue(this.mMinuteField, minute); + } + + // Update input element's .value if needed. + this.setInputValueFromFields(); + } + + clearInputFields(aFromInputElement) { + this.log("clearInputFields"); + + if (this.mHourField) { + this.clearFieldValue(this.mHourField); + } + + if (this.mMinuteField) { + this.clearFieldValue(this.mMinuteField); + } + + if (this.hasSecondField()) { + this.clearFieldValue(this.mSecondField); + } + + if (this.hasMillisecField()) { + this.clearFieldValue(this.mMillisecField); + } + + if (this.hasDayPeriodField()) { + this.clearFieldValue(this.mDayPeriodField); + } + + if (!aFromInputElement) { + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + } + } + + notifyMinMaxStepAttrChanged() { + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + // Fill in values again. + this.setFieldsFromInputValue(); + } + + incrementFieldValue(aTargetField, aTimes) { + let value = this.getFieldValue(aTargetField); + + // Use current time if field is empty. + if (this.isEmpty(value)) { + let now = new Date(); + + if (aTargetField == this.mHourField) { + value = now.getHours(); + if (this.mHour12) { + value = value % this.mMaxHour || this.mMaxHour; + } + } else if (aTargetField == this.mMinuteField) { + value = now.getMinutes(); + } else if (aTargetField == this.mSecondField) { + value = now.getSeconds(); + } else if (aTargetField == this.mMillisecField) { + value = now.getMilliseconds(); + } else { + this.log("Field not supported in incrementFieldValue."); + return; + } + } + + let min = aTargetField.getAttribute("min"); + let max = aTargetField.getAttribute("max"); + + value += Number(aTimes); + if (value > max) { + value -= max - min + 1; + } else if (value < min) { + value += max - min + 1; + } + + this.setFieldValue(aTargetField, value); + } + + handleKeyboardNav(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (this.hasDayPeriodField() && targetField == this.mDayPeriodField) { + // Home/End key does nothing on AM/PM field. + if (key == "Home" || key == "End") { + return; + } + + this.setDayPeriodValue( + this.getDayPeriodValue() == this.mAMIndicator + ? this.mPMIndicator + : this.mAMIndicator + ); + this.setInputValueFromFields(); + return; + } + + switch (key) { + case "ArrowUp": + this.incrementFieldValue(targetField, 1); + break; + case "ArrowDown": + this.incrementFieldValue(targetField, -1); + break; + case "PageUp": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, interval); + break; + } + case "PageDown": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, 0 - interval); + break; + } + case "Home": + let min = targetField.getAttribute("min"); + this.setFieldValue(targetField, min); + break; + case "End": + let max = targetField.getAttribute("max"); + this.setFieldValue(targetField, max); + break; + } + this.setInputValueFromFields(); + } + + handleKeypress(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (this.hasDayPeriodField() && targetField == this.mDayPeriodField) { + if (key == "a" || key == "A") { + this.setDayPeriodValue(this.mAMIndicator); + } else if (key == "p" || key == "P") { + this.setDayPeriodValue(this.mPMIndicator); + } + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + return; + } + + if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) { + let buffer = targetField.getAttribute("typeBuffer") || ""; + + buffer = buffer.concat(key); + this.setFieldValue(targetField, buffer); + + let n = Number(buffer); + let max = targetField.getAttribute("max"); + let maxLength = targetField.getAttribute("maxlength"); + if (buffer.length >= maxLength || n * 10 > max) { + buffer = ""; + this.advanceToNextField(); + } + targetField.setAttribute("typeBuffer", buffer); + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + } + } + + setFieldValue(aField, aValue) { + if (!aField || !aField.classList.contains("numeric")) { + return; + } + + let value = Number(aValue); + if (isNaN(value)) { + this.log("NaN on setFieldValue!"); + return; + } + + if (aField == this.mHourField) { + if (this.mHour12) { + // Try to change to 12hr format if user input is 0 or greater + // than 12. + let maxLength = aField.getAttribute("maxlength"); + if (value == 0 && aValue.length == maxLength) { + value = this.mMaxHour; + } else { + value = value > this.mMaxHour ? value % this.mMaxHour : value; + } + } else if (value > this.mMaxHour) { + value = this.mMaxHour; + } + } + + aField.setAttribute("value", value); + + let minDigits = aField.getAttribute("mindigits"); + let formatted = value.toLocaleString(this.mLocales, { + minimumIntegerDigits: minDigits, + useGrouping: false, + }); + + aField.textContent = formatted; + aField.setAttribute("aria-valuetext", formatted); + this.updateResetButtonVisibility(); + } + + getDayPeriodValue(aValue) { + if (!this.hasDayPeriodField()) { + return ""; + } + + let placeholder = this.mDayPeriodField.placeholder; + let value = this.mDayPeriodField.textContent; + + return value == placeholder ? "" : value; + } + + setDayPeriodValue(aValue) { + if (!this.hasDayPeriodField()) { + return; + } + + this.mDayPeriodField.textContent = aValue; + this.mDayPeriodField.setAttribute("value", aValue); + this.updateResetButtonVisibility(); + } + + isAnyFieldAvailable(aForPicker) { + let { hour, minute, second, millisecond } = this.getCurrentValue(); + let dayPeriod = this.getDayPeriodValue(); + + let available = !this.isEmpty(hour) || !this.isEmpty(minute); + if (available) { + return true; + } + + // Picker only cares about hour:minute. + if (aForPicker) { + return false; + } + + return ( + (this.hasDayPeriodField() && !this.isEmpty(dayPeriod)) || + (this.hasSecondField() && !this.isEmpty(second)) || + (this.hasMillisecField() && !this.isEmpty(millisecond)) + ); + } + + isAnyFieldEmpty() { + let { hour, minute, second, millisecond } = this.getCurrentValue(); + let dayPeriod = this.getDayPeriodValue(); + + return ( + this.isEmpty(hour) || + this.isEmpty(minute) || + (this.hasDayPeriodField() && this.isEmpty(dayPeriod)) || + (this.hasSecondField() && this.isEmpty(second)) || + (this.hasMillisecField() && this.isEmpty(millisecond)) + ); + } + + getCurrentValue() { + let hour = this.getFieldValue(this.mHourField); + if (!this.isEmpty(hour)) { + if (this.mHour12) { + let dayPeriod = this.getDayPeriodValue(); + if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { + hour += this.mMaxHour; + } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { + hour = 0; + } + } + } + + let minute = this.getFieldValue(this.mMinuteField); + let second = this.getFieldValue(this.mSecondField); + let millisecond = this.getFieldValue(this.mMillisecField); + + let time = { hour, minute, second, millisecond }; + + this.log("getCurrentValue: " + JSON.stringify(time)); + return time; + } +}; diff --git a/toolkit/content/widgets/dialog.js b/toolkit/content/widgets/dialog.js new file mode 100644 index 0000000000..e6d70934eb --- /dev/null +++ b/toolkit/content/widgets/dialog.js @@ -0,0 +1,517 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + class MozDialog extends MozXULElement { + constructor() { + super(); + + this.attachShadow({ mode: "open" }); + + document.addEventListener( + "keypress", + event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + this._hitEnter(event); + } else if ( + event.keyCode == KeyEvent.DOM_VK_ESCAPE && + !event.defaultPrevented + ) { + this.cancelDialog(); + } + }, + { mozSystemGroup: true } + ); + + if (AppConstants.platform == "macosx") { + document.addEventListener( + "keypress", + event => { + if (event.key == "." && event.metaKey) { + this.cancelDialog(); + } + }, + true + ); + } else { + this.addEventListener("focus", this, true); + this.shadowRoot.addEventListener("focus", this, true); + } + + // listen for when window is closed via native close buttons + window.addEventListener("close", event => { + if (!this.cancelDialog()) { + event.preventDefault(); + } + }); + + // for things that we need to initialize after onload fires + window.addEventListener("load", event => this.postLoadInit(event)); + } + + static get observedAttributes() { + return super.observedAttributes.concat("subdialog"); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "subdialog") { + console.assert( + newValue, + `Turning off subdialog style is not supported` + ); + if (this.isConnectedAndReady && !oldValue && newValue) { + this.shadowRoot.appendChild( + MozXULElement.parseXULToFragment(this.inContentStyle) + ); + } + return; + } + super.attributeChangedCallback(name, oldValue, newValue); + } + + static get inheritedAttributes() { + return { + ".dialog-button-box": + "pack=buttonpack,align=buttonalign,dir=buttondir,orient=buttonorient", + "[dlgtype='accept']": "disabled=buttondisabledaccept", + }; + } + + get inContentStyle() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + `; + } + + get _markup() { + let buttons = AppConstants.XP_UNIX + ? ` + <hbox class="dialog-button-box"> + <button dlgtype="disclosure" hidden="true"/> + <button dlgtype="help" hidden="true"/> + <button dlgtype="extra2" hidden="true"/> + <button dlgtype="extra1" hidden="true"/> + <spacer class="button-spacer" part="button-spacer" flex="1"/> + <button dlgtype="cancel"/> + <button dlgtype="accept"/> + </hbox>` + : ` + <hbox class="dialog-button-box" pack="end"> + <button dlgtype="extra2" hidden="true"/> + <spacer class="button-spacer" part="button-spacer" flex="1" hidden="true"/> + <button dlgtype="accept"/> + <button dlgtype="extra1" hidden="true"/> + <button dlgtype="cancel"/> + <button dlgtype="help" hidden="true"/> + <button dlgtype="disclosure" hidden="true"/> + </hbox>`; + + let key = + AppConstants.platform == "macosx" + ? `<key phase="capturing" + oncommand="document.querySelector('dialog').openHelp(event)" + key="&openHelpMac.commandkey;" modifiers="accel"/>` + : `<key phase="capturing" + oncommand="document.querySelector('dialog').openHelp(event)" + keycode="&openHelp.commandkey;"/>`; + + return ` + <html:link rel="stylesheet" href="chrome://global/skin/button.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/dialog.css"/> + ${this.hasAttribute("subdialog") ? this.inContentStyle : ""} + <vbox class="box-inherit dialog-content-box" part="content-box" flex="1"> + <html:slot></html:slot> + </vbox> + ${buttons} + <keyset>${key}</keyset>`; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + document.documentElement.setAttribute("role", "dialog"); + + this.shadowRoot.textContent = ""; + this.shadowRoot.appendChild( + MozXULElement.parseXULToFragment(this._markup, [ + "chrome://global/locale/globalKeys.dtd", + ]) + ); + this.initializeAttributeInheritance(); + + /** + * Gets populated by elements that are passed to document.l10n.setAttributes + * to localize the dialog buttons. Needed to properly size the dialog after + * the asynchronous translation. + */ + this._l10nButtons = []; + + this._configureButtons(this.buttons); + + window.moveToAlertPosition = this.moveToAlertPosition; + window.centerWindowOnScreen = this.centerWindowOnScreen; + } + + set buttons(val) { + this._configureButtons(val); + return val; + } + + get buttons() { + return this.getAttribute("buttons"); + } + + set defaultButton(val) { + this._setDefaultButton(val); + return val; + } + + get defaultButton() { + if (this.hasAttribute("defaultButton")) { + return this.getAttribute("defaultButton"); + } + return "accept"; // default to the accept button + } + + get _strBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://global/locale/dialog.properties" + ); + } + return this.__stringBundle; + } + + acceptDialog() { + return this._doButtonCommand("accept"); + } + + cancelDialog() { + return this._doButtonCommand("cancel"); + } + + getButton(aDlgType) { + return this._buttons[aDlgType]; + } + + get buttonBox() { + return this.shadowRoot.querySelector(".dialog-button-box"); + } + + moveToAlertPosition() { + // hack. we need this so the window has something like its final size + if (window.outerWidth == 1) { + dump( + "Trying to position a sizeless window; caller should have called sizeToContent() or sizeTo(). See bug 75649.\n" + ); + sizeToContent(); + } + + if (opener) { + var xOffset = (opener.outerWidth - window.outerWidth) / 2; + var yOffset = opener.outerHeight / 5; + + var newX = opener.screenX + xOffset; + var newY = opener.screenY + yOffset; + } else { + newX = (screen.availWidth - window.outerWidth) / 2; + newY = (screen.availHeight - window.outerHeight) / 2; + } + + // ensure the window is fully onscreen (if smaller than the screen) + if (newX < screen.availLeft) { + newX = screen.availLeft + 20; + } + if (newX + window.outerWidth > screen.availLeft + screen.availWidth) { + newX = screen.availLeft + screen.availWidth - window.outerWidth - 20; + } + + if (newY < screen.availTop) { + newY = screen.availTop + 20; + } + if (newY + window.outerHeight > screen.availTop + screen.availHeight) { + newY = screen.availTop + screen.availHeight - window.outerHeight - 60; + } + + window.moveTo(newX, newY); + } + + centerWindowOnScreen() { + var xOffset = screen.availWidth / 2 - window.outerWidth / 2; + var yOffset = screen.availHeight / 2 - window.outerHeight / 2; + + xOffset = xOffset > 0 ? xOffset : 0; + yOffset = yOffset > 0 ? yOffset : 0; + window.moveTo(xOffset, yOffset); + } + + postLoadInit(aEvent) { + let focusInit = () => { + const defaultButton = this.getButton(this.defaultButton); + + // give focus to the first focusable element in the dialog + let focusedElt = document.commandDispatcher.focusedElement; + if (!focusedElt) { + document.commandDispatcher.advanceFocusIntoSubtree(this); + + focusedElt = document.commandDispatcher.focusedElement; + if (focusedElt) { + var initialFocusedElt = focusedElt; + while ( + focusedElt.localName == "tab" || + focusedElt.getAttribute("noinitialfocus") == "true" + ) { + document.commandDispatcher.advanceFocusIntoSubtree(focusedElt); + focusedElt = document.commandDispatcher.focusedElement; + if (focusedElt) { + if (focusedElt == initialFocusedElt) { + if (focusedElt.getAttribute("noinitialfocus") == "true") { + focusedElt.blur(); + } + break; + } + } + } + + if (initialFocusedElt.localName == "tab") { + if (focusedElt.hasAttribute("dlgtype")) { + // We don't want to focus on anonymous OK, Cancel, etc. buttons, + // so return focus to the tab itself + initialFocusedElt.focus(); + } + } else if ( + AppConstants.platform != "macosx" && + focusedElt.hasAttribute("dlgtype") && + focusedElt != defaultButton + ) { + defaultButton.focus(); + } + } + } + + try { + if (defaultButton) { + window.notifyDefaultButtonLoaded(defaultButton); + } + } catch (e) {} + }; + + // Give focus after onload completes, see bug 103197. + setTimeout(focusInit, 0); + + if (this._l10nButtons.length) { + document.l10n.translateElements(this._l10nButtons).then(() => { + window.sizeToContent(); + }); + } + } + + openHelp(event) { + var helpButton = this.getButton("help"); + if (helpButton.disabled || helpButton.hidden) { + return; + } + this._fireButtonEvent("help"); + event.stopPropagation(); + event.preventDefault(); + } + + _configureButtons(aButtons) { + // by default, get all the anonymous button elements + var buttons = {}; + this._buttons = buttons; + + for (let type of [ + "accept", + "cancel", + "extra1", + "extra2", + "help", + "disclosure", + ]) { + buttons[type] = this.shadowRoot.querySelector(`[dlgtype="${type}"]`); + } + + // look for any overriding explicit button elements + var exBtns = this.getElementsByAttribute("dlgtype", "*"); + var dlgtype; + for (let i = 0; i < exBtns.length; ++i) { + dlgtype = exBtns[i].getAttribute("dlgtype"); + buttons[dlgtype].hidden = true; // hide the anonymous button + buttons[dlgtype] = exBtns[i]; + } + + // add the label and oncommand handler to each button + for (dlgtype in buttons) { + var button = buttons[dlgtype]; + button.addEventListener( + "command", + this._handleButtonCommand.bind(this), + true + ); + + // don't override custom labels with pre-defined labels on explicit buttons + if (!button.hasAttribute("label")) { + // dialog attributes override the default labels in dialog.properties + if (this.hasAttribute("buttonlabel" + dlgtype)) { + button.setAttribute( + "label", + this.getAttribute("buttonlabel" + dlgtype) + ); + if (this.hasAttribute("buttonaccesskey" + dlgtype)) { + button.setAttribute( + "accesskey", + this.getAttribute("buttonaccesskey" + dlgtype) + ); + } + } else if (this.hasAttribute("buttonid" + dlgtype)) { + document.l10n.setAttributes( + button, + this.getAttribute("buttonid" + dlgtype) + ); + this._l10nButtons.push(button); + } else if (dlgtype != "extra1" && dlgtype != "extra2") { + button.setAttribute( + "label", + this._strBundle.GetStringFromName("button-" + dlgtype) + ); + var accessKey = this._strBundle.GetStringFromName( + "accesskey-" + dlgtype + ); + if (accessKey) { + button.setAttribute("accesskey", accessKey); + } + } + } + } + + // ensure that hitting enter triggers the default button command + // eslint-disable-next-line no-self-assign + this.defaultButton = this.defaultButton; + + // if there is a special button configuration, use it + if (aButtons) { + // expect a comma delimited list of dlgtype values + var list = aButtons.split(","); + + // mark shown dlgtypes as true + var shown = { + accept: false, + cancel: false, + help: false, + disclosure: false, + extra1: false, + extra2: false, + }; + for (let i = 0; i < list.length; ++i) { + shown[list[i].replace(/ /g, "")] = true; + } + + // hide/show the buttons we want + for (dlgtype in buttons) { + buttons[dlgtype].hidden = !shown[dlgtype]; + } + + // show the spacer on Windows only when the extra2 button is present + if (AppConstants.platform == "win") { + let spacer = this.shadowRoot.querySelector(".button-spacer"); + spacer.removeAttribute("hidden"); + spacer.setAttribute("flex", shown.extra2 ? "1" : "0"); + } + } + } + + _setDefaultButton(aNewDefault) { + // remove the default attribute from the previous default button, if any + var oldDefaultButton = this.getButton(this.defaultButton); + if (oldDefaultButton) { + oldDefaultButton.removeAttribute("default"); + } + + var newDefaultButton = this.getButton(aNewDefault); + if (newDefaultButton) { + this.setAttribute("defaultButton", aNewDefault); + newDefaultButton.setAttribute("default", "true"); + } else { + this.setAttribute("defaultButton", "none"); + if (aNewDefault != "none") { + dump( + "invalid new default button: " + aNewDefault + ", assuming: none\n" + ); + } + } + } + + _handleButtonCommand(aEvent) { + return this._doButtonCommand(aEvent.target.getAttribute("dlgtype")); + } + + _doButtonCommand(aDlgType) { + var button = this.getButton(aDlgType); + if (!button.disabled) { + var noCancel = this._fireButtonEvent(aDlgType); + if (noCancel) { + if (aDlgType == "accept" || aDlgType == "cancel") { + var closingEvent = new CustomEvent("dialogclosing", { + bubbles: true, + detail: { button: aDlgType }, + }); + this.dispatchEvent(closingEvent); + window.close(); + } + } + return noCancel; + } + return true; + } + + _fireButtonEvent(aDlgType) { + var event = document.createEvent("Events"); + event.initEvent("dialog" + aDlgType, true, true); + + // handle dom event handlers + return this.dispatchEvent(event); + } + + _hitEnter(evt) { + if (evt.defaultPrevented) { + return; + } + + var btn = this.getButton(this.defaultButton); + if (btn) { + this._doButtonCommand(this.defaultButton); + } + } + + on_focus(event) { + let btn = this.getButton(this.defaultButton); + if (btn) { + btn.setAttribute( + "default", + event.originalTarget == btn || + !( + event.originalTarget.localName == "button" || + event.originalTarget.localName == "toolbarbutton" + ) + ); + } + } + } + + customElements.define("dialog", MozDialog); +} diff --git a/toolkit/content/widgets/docs/index.rst b/toolkit/content/widgets/docs/index.rst new file mode 100644 index 0000000000..2e576357f3 --- /dev/null +++ b/toolkit/content/widgets/docs/index.rst @@ -0,0 +1,10 @@ +=============== +Toolkit Widgets +=============== + +The ``/toolkit/content/widgets`` directory contains XBL bindings, Mozilla Custom Elements, and UA Widgets usable for all applications. + +.. toctree:: + :maxdepth: 1 + + ua_widget diff --git a/toolkit/content/widgets/docs/ua_widget.rst b/toolkit/content/widgets/docs/ua_widget.rst new file mode 100644 index 0000000000..80f3792713 --- /dev/null +++ b/toolkit/content/widgets/docs/ua_widget.rst @@ -0,0 +1,58 @@ +UA Widgets +========== + +Introduction +------------ + +UA Widgets are intended to be a replacement of our usage of XBL bindings in web content. These widgets run JavaScript inside extended principal per-origin sandboxes. They insert their own DOM inside of a special, closed Shadow Root inaccessible to the page, called a UA Widget Shadow Root. + +UA Widget lifecycle +------------------- + +UA Widgets are generally constructed when the element is appended to the document and destroyed when the element is removed from the tree. Yet, in order to be fast, specialization was made to each of the widgets. + +When the element is appended to the tree, a chrome-only ``UAWidgetSetupOrChange`` event is dispatched and is caught by a frame script, namely UAWidgetsChild. + +UAWidgetsChild then grabs the sandbox for that origin (lazily creating it as needed), loads the script as needed, and initializes an instance by calling the JS constructor with a reference to the UA Widget Shadow Root created by the DOM. We will discuss the sandbox in the latter section. + +The ``onsetup`` method is called right after the instance is constructed. The call to constructor must not throw, or UAWidgetsChild will be confused since an instance of the widget will not be returned, but the widget is already half-initalized. If the ``onsetup`` method call throws, UAWidgetsChild will still be able to hold the reference of the widget and call the destructor later on. + +When the element is removed from the tree, ``UAWidgetTeardown`` is dispatched so UAWidgetsChild can destroy the widget, if it exists. If so, the UAWidgetsChild calls the ``destructor()`` method on the widget, causing the widget to destruct itself. + +Counter-intuitively, elements are not considered "removed from the tree" when the document is unloaded. This is considered safe as anything the widget touches should be reset or cleaned up when the document unloads. Please do not violate the assumption by having any browser state toggled by the destructor. + +When a UA Widget initializes, it should create its own DOM inside the passed UA Widget Shadow Root, including the ``<link>`` element necessary to load the stylesheet, add event listeners, etc. When destroyed (i.e. the destructor method is called), it should do the opposite. + +**Specialization**: for video controls, we do not want to do the work if the control is not needed (i.e. when the ``<video>`` or ``<audio>`` element has no "controls" attribute set), so we forgo dispatching the event from HTMLMediaElement in the BindToTree method. Instead, another ``UAWidgetSetupOrChange`` event will cause the sandbox and the widget instance to construct when the attribute is set to true. The same event is also responsible for triggering the ``onchange()`` method on UA Widgets if the widget is already initialized. + +Likewise, the datetime box widget is only loaded when the ``type`` attribute of an ``<input>`` is either `date` or `time`. + +The specialization does not apply to the lifecycle of the UA Widget Shadow Root. It is always constructed in order to suppress children of the DOM element from the web content from receiving a layout frame. + +UA Widget Shadow Root +--------------------- + +The UA Widget Shadow Root is a closed shadow root, with the UA Widget flag turned on. As a closed shadow root, it may not be accessed by other scripts. It is attached on host element which the spec disallow a shadow root to be attached. + +The UA Widget flag enables the security feature covered in the next section. + +**Side note**: XML pretty print hides its transformed content inside a UA Widget Shadow DOM as well, even though there isn't any JavaScript to run. This is set in order to leverage the same security feature and behaviors there. + +The JavaScript sandbox +---------------------- + +The sandboxes created for UA Widgets are per-origin and set to the expanded principal. This allows the script to access other DOM APIs unavailable to the web content, while keeping its principal tied to the document origin. They are created as needed, backing the lifecycle of the UA Widgets as previously mentioned. These sandbox globals are not associated with any window object because they are shared across all same-origin documents. It is the job of the UA Widget script to hold and manage the references of the window and document objects the widget is being initiated on, by accessing them from the UA Widget Shadow Root instance passed. + +While the closed shadow root technically prevents content from accessing the contents, we want a stronger guarantee to protect against accidental leakage of references to the UA Widget shadow tree into content script. Access to the UA Widget DOM is restricted by having their reflectors set in the UA Widgets scope, as opposed to the normal scope. To accomplish this, we avoid having any script (UA Widget script included) getting a hold of the reference of any created DOM element before appending to the Shadow DOM. Once the element is in the Shadow DOM, the binding mechanism will put the reflector in the desired scope as it is being accessed. + +To avoid creating reflectors before DOM insertion, the available DOM interfaces are limited. For example, instead of ``createElement()`` and ``appendChild()``, the script would have to call ``createElementAndAppendChildAt()`` available on the UA Widget Shadow Root instance, to avoid receiving a reference to the DOM element and thus triggering the creation of its reflector in the wrong scope, before the element is properly associated with the UA Widget shadow tree. To find out the differences, search for ``Func="IsChromeOrUAWidget"`` and ``Func="IsNotUAWidget"`` in in-tree WebIDL files. + +Other things to watch out for +----------------------------- + +As part of the implementation of the Web Platform, it is important to make sure the web-observable characteristics of the widget correctly reflect what the script on the web expects. + +* Do not dispatch non-spec compliant events on the UA Widget Shadow Root host element, as event listeners in web content scripts can access them. +* The layout and the dimensions of the widget should be ready by the time the constructor and ``onsetup`` returns, since they can be detectable as soon as the content script gets the reference of the host element (i.e. when ``appendChild()`` returns). In order to make this easier we load ``<link>`` elements load chrome stylesheets synchronously when inside a UA Widget Shadow DOM. +* There shouldn't be any white-spaces nodes in the Shadow DOM, because UA Widget could be placed inside ``white-space: pre``. See bug 1502205. +* CSP will block inline styles in the Shadow DOM. ``<link>`` is the only safe way to load styles. diff --git a/toolkit/content/widgets/editor.js b/toolkit/content/widgets/editor.js new file mode 100644 index 0000000000..ef0f02a044 --- /dev/null +++ b/toolkit/content/widgets/editor.js @@ -0,0 +1,215 @@ +/* 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 is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + /* globals XULFrameElement */ + + class MozEditor extends XULFrameElement { + connectedCallback() { + this._editorContentListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIURIContentListener", + "nsISupportsWeakReference", + ]), + doContent(contentType, isContentPreferred, request, contentHandler) { + return false; + }, + isPreferred(contentType, desiredContentType) { + return false; + }, + canHandleContent(contentType, isContentPreferred, desiredContentType) { + return false; + }, + loadCookie: null, + parentContentListener: null, + }; + + this._finder = null; + + this._fastFind = null; + + this._lastSearchString = null; + + // Make window editable immediately only + // if the "editortype" attribute is supplied + // This allows using same contentWindow for different editortypes, + // where the type is determined during the apps's window.onload handler. + if (this.editortype) { + this.makeEditable(this.editortype, true); + } + } + + get finder() { + if (!this._finder) { + if (!this.docShell) { + return null; + } + + let Finder = ChromeUtils.import("resource://gre/modules/Finder.jsm", {}) + .Finder; + this._finder = new Finder(this.docShell); + } + return this._finder; + } + + get fastFind() { + if (!this._fastFind) { + if (!("@mozilla.org/typeaheadfind;1" in Cc)) { + return null; + } + + if (!this.docShell) { + return null; + } + + this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance( + Ci.nsITypeAheadFind + ); + this._fastFind.init(this.docShell); + } + return this._fastFind; + } + + set editortype(val) { + this.setAttribute("editortype", val); + } + + get editortype() { + return this.getAttribute("editortype"); + } + + get currentURI() { + return this.webNavigation.currentURI; + } + + get webBrowserFind() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + + get markupDocumentViewer() { + return this.docShell.contentViewer; + } + + get editingSession() { + return this.docShell.editingSession; + } + + get commandManager() { + return this.webNavigation + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsICommandManager); + } + + set fullZoom(val) { + this.browsingContext.fullZoom = val; + } + + get fullZoom() { + return this.browsingContext.fullZoom; + } + + set textZoom(val) { + this.browsingContext.textZoom = val; + } + + get textZoom() { + return this.browsingContext.textZoom; + } + + get isSyntheticDocument() { + return this.contentDocument.isSyntheticDocument; + } + + get messageManager() { + if (this.frameLoader) { + return this.frameLoader.messageManager; + } + return null; + } + + // Copied from toolkit/content/widgets/browser-custom-element.js. + // Send an asynchronous message to the remote child via an actor. + // Note: use this only for messages through an actor. For old-style + // messages, use the message manager. + // The value of the scope argument determines which browsing contexts + // are sent to: + // 'all' - send to actors associated with all descendant child frames. + // 'roots' - send only to actors associated with process roots. + // undefined/'' - send only to the top-level actor and not any descendants. + sendMessageToActor(messageName, args, actorName, scope) { + if (!this.frameLoader) { + return; + } + + function sendToChildren(browsingContext, childScope) { + let windowGlobal = browsingContext.currentWindowGlobal; + // If 'roots' is set, only send if windowGlobal.isProcessRoot is true. + if ( + windowGlobal && + (childScope != "roots" || windowGlobal.isProcessRoot) + ) { + windowGlobal.getActor(actorName).sendAsyncMessage(messageName, args); + } + + // Iterate as long as scope in assigned. Note that we use the original + // passed in scope, not childScope here. + if (scope) { + for (let context of browsingContext.children) { + sendToChildren(context, scope); + } + } + } + + // Pass no second argument to always send to the top-level browsing context. + sendToChildren(this.browsingContext); + } + + get outerWindowID() { + return this.docShell.outerWindowID; + } + + makeEditable(editortype, waitForUrlLoad) { + let win = this.contentWindow; + this.editingSession.makeWindowEditable( + win, + editortype, + waitForUrlLoad, + true, + false + ); + this.setAttribute("editortype", editortype); + + this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface( + Ci.nsIURIContentListener + ).parentContentListener = this._editorContentListener; + } + + getEditor(containingWindow) { + return this.editingSession.getEditorForWindow(containingWindow); + } + + getHTMLEditor(containingWindow) { + var editor = this.editingSession.getEditorForWindow(containingWindow); + return editor.QueryInterface(Ci.nsIHTMLEditor); + } + + print(aOuterWindowID, aPrintSettings) { + if (!this.frameLoader) { + throw Components.Exception("No frame loader.", Cr.NS_ERROR_FAILURE); + } + + return this.frameLoader.print(aOuterWindowID, aPrintSettings); + } + } + + customElements.define("editor", MozEditor); +} diff --git a/toolkit/content/widgets/findbar.js b/toolkit/content/widgets/findbar.js new file mode 100644 index 0000000000..a1bd3fd58b --- /dev/null +++ b/toolkit/content/widgets/findbar.js @@ -0,0 +1,1398 @@ +/* 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 is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + let LazyConstants = {}; + ChromeUtils.defineModuleGetter( + LazyConstants, + "PluralForm", + "resource://gre/modules/PluralForm.jsm" + ); + + const PREFS_TO_OBSERVE_BOOL = new Map([ + ["findAsYouType", "accessibility.typeaheadfind"], + ["manualFAYT", "accessibility.typeaheadfind.manual"], + ["typeAheadLinksOnly", "accessibility.typeaheadfind.linksonly"], + ["entireWord", "findbar.entireword"], + ["highlightAll", "findbar.highlightAll"], + ["useModalHighlight", "findbar.modalHighlight"], + ]); + const PREFS_TO_OBSERVE_INT = new Map([ + ["typeAheadCaseSensitive", "accessibility.typeaheadfind.casesensitive"], + ["matchDiacritics", "findbar.matchdiacritics"], + ]); + const PREFS_TO_OBSERVE_ALL = new Map([ + ...PREFS_TO_OBSERVE_BOOL, + ...PREFS_TO_OBSERVE_INT, + ]); + const TOPIC_MAC_APP_ACTIVATE = "mac_app_activate"; + + class MozFindbar extends MozXULElement { + static get markup() { + return ` + <hbox anonid="findbar-container" class="findbar-container" flex="1" align="center"> + <hbox anonid="findbar-textbox-wrapper" align="stretch"> + <html:input anonid="findbar-textbox" class="findbar-textbox findbar-find-fast" /> + <toolbarbutton anonid="find-previous" class="findbar-find-previous tabbable" + data-l10n-attrs="tooltiptext" data-l10n-id="findbar-previous" + oncommand="onFindAgainCommand(true);" disabled="true" /> + <toolbarbutton anonid="find-next" class="findbar-find-next tabbable" + data-l10n-id="findbar-next" oncommand="onFindAgainCommand(false);" disabled="true" /> + </hbox> + <toolbarbutton anonid="highlight" class="findbar-highlight findbar-button tabbable" + data-l10n-id="findbar-highlight-all2" oncommand="toggleHighlight(this.checked);" type="checkbox" /> + <toolbarbutton anonid="find-case-sensitive" class="findbar-case-sensitive findbar-button tabbable" + data-l10n-id="findbar-case-sensitive" oncommand="_setCaseSensitivity(this.checked ? 1 : 0);" type="checkbox" /> + <toolbarbutton anonid="find-match-diacritics" class="findbar-match-diacritics findbar-button tabbable" + data-l10n-id="findbar-match-diacritics" oncommand="_setDiacriticMatching(this.checked ? 1 : 0);" type="checkbox" /> + <toolbarbutton anonid="find-entire-word" class="findbar-entire-word findbar-button tabbable" + data-l10n-id="findbar-entire-word" oncommand="toggleEntireWord(this.checked);" type="checkbox" /> + <label anonid="match-case-status" class="findbar-find-fast" /> + <label anonid="match-diacritics-status" class="findbar-find-fast" /> + <label anonid="entire-word-status" class="findbar-find-fast" /> + <label anonid="found-matches" class="findbar-find-fast found-matches" hidden="true" /> + <image anonid="find-status-icon" class="findbar-find-fast find-status-icon" /> + <description anonid="find-status" control="findbar-textbox" class="findbar-find-fast findbar-find-status" /> + </hbox> + <toolbarbutton anonid="find-closebutton" class="findbar-closebutton close-icon" + data-l10n-id="findbar-find-button-close" oncommand="close();" /> + `; + } + + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/findbar.ftl"); + this.destroy = this.destroy.bind(this); + + // We have to guard against `this.close` being |null| due to an unknown + // issue, which is tracked in bug 957999. + this.addEventListener( + "keypress", + event => { + if (event.keyCode == event.DOM_VK_ESCAPE) { + if (this.close) { + this.close(); + } + event.preventDefault(); + } + }, + true + ); + } + + connectedCallback() { + // Hide the findbar immediately without animation. This prevents a flicker in the case where + // we'll never be shown (i.e. adopting a tab that has a previously-opened-but-now-closed + // findbar into a new window). + this.setAttribute("noanim", "true"); + this.hidden = true; + this.appendChild(this.constructor.fragment); + + /** + * Please keep in sync with toolkit/modules/FindBarContent.jsm + */ + this.FIND_NORMAL = 0; + + this.FIND_TYPEAHEAD = 1; + + this.FIND_LINKS = 2; + + this._findMode = 0; + + this._flashFindBar = 0; + + this._initialFlashFindBarCount = 6; + + /** + * For tests that need to know when the find bar is finished + * initializing, we store a promise to notify on. + */ + this._startFindDeferred = null; + + this._browser = null; + + this._destroyed = false; + + this._strBundle = null; + + this._xulBrowserWindow = null; + + // These elements are accessed frequently and are therefore cached. + this._findField = this.getElement("findbar-textbox"); + this._foundMatches = this.getElement("found-matches"); + this._findStatusIcon = this.getElement("find-status-icon"); + this._findStatusDesc = this.getElement("find-status"); + + this._foundURL = null; + + let prefsvc = Services.prefs; + + this.quickFindTimeoutLength = prefsvc.getIntPref( + "accessibility.typeaheadfind.timeout" + ); + this._flashFindBar = prefsvc.getIntPref( + "accessibility.typeaheadfind.flashBar" + ); + + let observe = (this._observe = this.observe.bind(this)); + for (let [propName, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.addObserver(prefName, observe); + let prefGetter = PREFS_TO_OBSERVE_BOOL.has(propName) ? "Bool" : "Int"; + this["_" + propName] = prefsvc[`get${prefGetter}Pref`](prefName); + } + Services.obs.addObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + this._findResetTimeout = -1; + + // Make sure the FAYT keypress listener is attached by initializing the + // browser property. + if (this.getAttribute("browserid")) { + setTimeout(() => { + // eslint-disable-next-line no-self-assign + this.browser = this.browser; + }, 0); + } + + window.addEventListener("unload", this.destroy); + + this._findField.addEventListener("input", event => { + // We should do nothing during composition. E.g., composing string + // before converting may matches a forward word of expected word. + // After that, even if user converts the composition string to the + // expected word, it may find second or later searching word in the + // document. + if (this._isIMEComposing) { + return; + } + + const value = this._findField.value; + if (this._hadValue && !value) { + this._willfullyDeleted = true; + this._hadValue = false; + } else if (value.trim()) { + this._hadValue = true; + this._willfullyDeleted = false; + } + this._find(value); + }); + + this._findField.addEventListener("keypress", event => { + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: + if (this.findMode == this.FIND_NORMAL) { + let findString = this._findField; + if (!findString.value) { + return; + } + if (event.getModifierState("Accel")) { + this.getElement("highlight").click(); + return; + } + + this.onFindAgainCommand(event.shiftKey); + } else { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_TAB: + let shouldHandle = + !event.altKey && !event.ctrlKey && !event.metaKey; + if (shouldHandle && this.findMode != this.FIND_NORMAL) { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + this.browser.finder.keyPress(event); + event.preventDefault(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + this.browser.finder.keyPress(event); + event.preventDefault(); + break; + } + }); + + this._findField.addEventListener("blur", event => { + // Note: This code used to remove the selection + // if it matched an editable. + this.browser.finder.enableSelection(); + }); + + this._findField.addEventListener("focus", event => { + this._updateBrowserWithState(); + }); + + this._findField.addEventListener("compositionstart", event => { + // Don't close the find toolbar while IME is composing. + let findbar = this; + findbar._isIMEComposing = true; + if (findbar._quickFindTimeout) { + clearTimeout(findbar._quickFindTimeout); + findbar._quickFindTimeout = null; + findbar._updateBrowserWithState(); + } + }); + + this._findField.addEventListener("compositionend", event => { + this._isIMEComposing = false; + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + }); + + this._findField.addEventListener("dragover", event => { + if (event.dataTransfer.types.includes("text/plain")) { + event.preventDefault(); + } + }); + + this._findField.addEventListener("drop", event => { + let value = event.dataTransfer.getData("text/plain"); + this._findField.value = value; + this._find(value); + event.stopPropagation(); + event.preventDefault(); + }); + } + + set findMode(val) { + this._findMode = val; + this._updateBrowserWithState(); + return val; + } + + get findMode() { + return this._findMode; + } + + set prefillWithSelection(val) { + this.setAttribute("prefillwithselection", val); + return val; + } + + get prefillWithSelection() { + return this.getAttribute("prefillwithselection") != "false"; + } + + get hasTransactions() { + if (this._findField.value) { + return true; + } + + // Watch out for lazy editor init + if (this._findField.editor) { + let tm = this._findField.editor.transactionManager; + return !!(tm.numberOfUndoItems || tm.numberOfRedoItems); + } + return false; + } + + set browser(val) { + function setFindbarInActor(browser, findbar) { + if (!browser.frameLoader) { + return; + } + + let windowGlobal = browser.browsingContext.currentWindowGlobal; + if (windowGlobal) { + let findbarParent = windowGlobal.getActor("FindBar"); + if (findbarParent) { + findbarParent.setFindbar(browser, findbar); + } + } + } + + if (this._browser) { + setFindbarInActor(this._browser, null); + + let finder = this._browser.finder; + if (finder) { + finder.removeResultListener(this); + } + } + + this._browser = val; + if (this._browser) { + // Need to do this to ensure the correct initial state. + this._updateBrowserWithState(); + + setFindbarInActor(this._browser, this); + + this._browser.finder.addResultListener(this); + } + return val; + } + + get browser() { + if (!this._browser) { + this._browser = document.getElementById(this.getAttribute("browserid")); + } + return this._browser; + } + + get strBundle() { + if (!this._strBundle) { + this._strBundle = Services.strings.createBundle( + "chrome://global/locale/findbar.properties" + ); + } + return this._strBundle; + } + + observe(subject, topic, prefName) { + if (topic == TOPIC_MAC_APP_ACTIVATE) { + this._onAppActivateMac(); + return; + } + + if (topic != "nsPref:changed") { + return; + } + + let prefsvc = Services.prefs; + + switch (prefName) { + case "accessibility.typeaheadfind": + this._findAsYouType = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.manual": + this._manualFAYT = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.timeout": + this.quickFindTimeoutLength = prefsvc.getIntPref(prefName); + break; + case "accessibility.typeaheadfind.linksonly": + this._typeAheadLinksOnly = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.casesensitive": + this._setCaseSensitivity(prefsvc.getIntPref(prefName)); + break; + case "findbar.entireword": + this._entireWord = prefsvc.getBoolPref(prefName); + this.toggleEntireWord(this._entireWord, true); + break; + case "findbar.highlightAll": + this.toggleHighlight(prefsvc.getBoolPref(prefName), true); + break; + case "findbar.matchdiacritics": + this._setDiacriticMatching(prefsvc.getIntPref(prefName)); + break; + case "findbar.modalHighlight": + this._useModalHighlight = prefsvc.getBoolPref(prefName); + if (this.browser.finder) { + this.browser.finder.onModalHighlightChange(this._useModalHighlight); + } + break; + } + } + + getElement(aAnonymousID) { + return this.querySelector(`[anonid=${aAnonymousID}]`); + } + + /** + * This is necessary because the destructor isn't called when we are removed + * from a document that is not destroyed. This needs to be explicitly called + * in this case. + */ + destroy() { + if (this._destroyed) { + return; + } + window.removeEventListener("unload", this.destroy); + this._destroyed = true; + + if (this.browser && this.browser.finder) { + this.browser.finder.destroy(); + } + + // Invoking this setter also removes the message listeners. + this.browser = null; + + let prefsvc = Services.prefs; + let observe = this._observe; + for (let [, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.removeObserver(prefName, observe); + } + + Services.obs.removeObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + // Clear all timers that might still be running. + this._cancelTimers(); + } + + _cancelTimers() { + if (this._flashFindBarTimeout) { + clearInterval(this._flashFindBarTimeout); + this._flashFindBarTimeout = null; + } + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + this._quickFindTimeout = null; + } + if (this._findResetTimeout) { + clearTimeout(this._findResetTimeout); + this._findResetTimeout = null; + } + } + + _setFindCloseTimeout() { + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + } + + // Don't close the find toolbar while IME is composing OR when the + // findbar is already hidden. + if (this._isIMEComposing || this.hidden) { + this._quickFindTimeout = null; + this._updateBrowserWithState(); + return; + } + + if (this.quickFindTimeoutLength < 1) { + this._quickFindTimeout = null; + } else { + this._quickFindTimeout = setTimeout(() => { + if (this.findMode != this.FIND_NORMAL) { + this.close(); + } + this._quickFindTimeout = null; + }, this.quickFindTimeoutLength); + } + this._updateBrowserWithState(); + } + + /** + * Updates the search match count after each find operation on a new string. + */ + _updateMatchesCount() { + if (!this._dispatchFindEvent("matchescount")) { + return; + } + + this.browser.finder.requestMatchesCount( + this._findField.value, + this.findMode == this.FIND_LINKS + ); + } + + /** + * Turns highlighting of all occurrences on or off. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + toggleHighlight(highlight, fromPrefObserver) { + if (highlight === this._highlightAll) { + return; + } + + this.browser.finder.onHighlightAllChange(highlight); + + this._setHighlightAll(highlight, fromPrefObserver); + + if (!this._dispatchFindEvent("highlightallchange")) { + return; + } + + let word = this._findField.value; + // Bug 429723. Don't attempt to highlight "" + if (highlight && !word) { + return; + } + + this.browser.finder.highlight( + highlight, + word, + this.findMode == this.FIND_LINKS + ); + + // Update the matches count + this._updateMatchesCount(Ci.nsITypeAheadFind.FIND_FOUND); + } + + /** + * Updates the highlight-all mode of the findbar and its UI. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + _setHighlightAll(highlight, fromPrefObserver) { + if (typeof highlight != "boolean") { + highlight = this._highlightAll; + } + if (highlight !== this._highlightAll) { + this._highlightAll = highlight; + if (!fromPrefObserver) { + Services.telemetry.scalarAdd("findbar.highlight_all", 1); + Services.prefs.setBoolPref("findbar.highlightAll", highlight); + } + } + let checkbox = this.getElement("highlight"); + checkbox.checked = this._highlightAll; + } + + /** + * Updates the case-sensitivity mode of the findbar and its UI. + * + * @param {String} [str] The string for which case sensitivity might be + * turned on. This only used when case-sensitivity is + * in auto mode, see `_shouldBeCaseSensitive`. The + * default value for this parameter is the find-field + * value. + * @see _shouldBeCaseSensitive + */ + _updateCaseSensitivity(str) { + let val = str || this._findField.value; + + let caseSensitive = this._shouldBeCaseSensitive(val); + let checkbox = this.getElement("find-case-sensitive"); + let statusLabel = this.getElement("match-case-status"); + checkbox.checked = caseSensitive; + + statusLabel.value = caseSensitive ? this._caseSensitiveStr : ""; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + let hideCheckbox = + this.findMode != this.FIND_NORMAL || + (this._typeAheadCaseSensitive != 0 && + this._typeAheadCaseSensitive != 1); + checkbox.hidden = hideCheckbox; + statusLabel.hidden = !hideCheckbox; + + this.browser.finder.caseSensitive = caseSensitive; + } + + /** + * Sets the findbar case-sensitivity mode. + * + * @param {Number} caseSensitivity 0 - case insensitive, + * 1 - case sensitive, + * 2 - auto = case sensitive if the matching + * string contains upper case letters. + * @see _shouldBeCaseSensitive + */ + _setCaseSensitivity(caseSensitivity) { + this._typeAheadCaseSensitive = caseSensitivity; + this._updateCaseSensitivity(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("casesensitivitychange"); + Services.telemetry.scalarAdd("findbar.match_case", 1); + } + + /** + * Updates the diacritic-matching mode of the findbar and its UI. + * + * @param {String} [str] The string for which diacritic matching might be + * turned on. This is only used when diacritic + * matching is in auto mode, see + * `_shouldMatchDiacritics`. The default value for + * this parameter is the find-field value. + * @see _shouldMatchDiacritics. + */ + _updateDiacriticMatching(str) { + let val = str || this._findField.value; + + let matchDiacritics = this._shouldMatchDiacritics(val); + let checkbox = this.getElement("find-match-diacritics"); + let statusLabel = this.getElement("match-diacritics-status"); + checkbox.checked = matchDiacritics; + + statusLabel.value = matchDiacritics ? this._matchDiacriticsStr : ""; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + let hideCheckbox = + this.findMode != this.FIND_NORMAL || + (this._matchDiacritics != 0 && this._matchDiacritics != 1); + checkbox.hidden = hideCheckbox; + statusLabel.hidden = !hideCheckbox; + + this.browser.finder.matchDiacritics = matchDiacritics; + } + + /** + * Sets the findbar diacritic-matching mode + * @param {Number} diacriticMatching 0 - ignore diacritics, + * 1 - match diacritics, + * 2 - auto = match diacritics if the + * matching string contains + * diacritics. + * @see _shouldMatchDiacritics + */ + _setDiacriticMatching(diacriticMatching) { + this._matchDiacritics = diacriticMatching; + this._updateDiacriticMatching(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("diacriticmatchingchange"); + + Services.telemetry.scalarAdd("findbar.match_diacritics", 1); + } + + /** + * Updates the entire-word mode of the findbar and its UI. + */ + _setEntireWord() { + let entireWord = this._entireWord; + let checkbox = this.getElement("find-entire-word"); + let statusLabel = this.getElement("entire-word-status"); + checkbox.checked = entireWord; + + statusLabel.value = entireWord ? this._entireWordStr : ""; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + let hideCheckbox = this.findMode != this.FIND_NORMAL; + checkbox.hidden = hideCheckbox; + statusLabel.hidden = !hideCheckbox; + + this.browser.finder.entireWord = entireWord; + } + + /** + * Sets the findbar entire-word mode. + * + * @param {Boolean} entireWord Whether or not entire-word mode should be + * turned on. + */ + toggleEntireWord(entireWord, fromPrefObserver) { + if (!fromPrefObserver) { + // Just set the pref; our observer will change the find bar behavior. + Services.prefs.setBoolPref("findbar.entireword", entireWord); + + Services.telemetry.scalarAdd("findbar.whole_words", 1); + return; + } + + this._findFailedString = null; + this._find(); + } + + /** + * Opens and displays the find bar. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Boolean} `true` if the find bar wasn't previously open, `false` + * otherwise. + */ + open(mode) { + if (mode != undefined) { + this.findMode = mode; + } + + if (!this._notFoundStr) { + var bundle = this.strBundle; + this._notFoundStr = bundle.GetStringFromName("NotFound"); + this._wrappedToTopStr = bundle.GetStringFromName("WrappedToTop"); + this._wrappedToBottomStr = bundle.GetStringFromName("WrappedToBottom"); + this._normalFindStr = bundle.GetStringFromName("NormalFind"); + this._fastFindStr = bundle.GetStringFromName("FastFind"); + this._fastFindLinksStr = bundle.GetStringFromName("FastFindLinks"); + this._caseSensitiveStr = bundle.GetStringFromName("CaseSensitive"); + this._matchDiacriticsStr = bundle.GetStringFromName("MatchDiacritics"); + this._entireWordStr = bundle.GetStringFromName("EntireWord"); + } + + this._findFailedString = null; + + this._updateFindUI(); + if (this.hidden) { + Services.telemetry.scalarAdd("findbar.shown", 1); + this.removeAttribute("noanim"); + this.hidden = false; + + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + + let event = document.createEvent("Events"); + event.initEvent("findbaropen", true, false); + this.dispatchEvent(event); + + this.browser.finder.onFindbarOpen(); + + return true; + } + return false; + } + + /** + * Closes the findbar. + * + * @param {Boolean} [noAnim] Whether to disable to closing animation. Used + * to close instantly and synchronously, when + * other operations depend on this state. + */ + close(noAnim) { + if (this.hidden) { + return; + } + + if (noAnim) { + this.setAttribute("noanim", true); + } + this.hidden = true; + + let event = document.createEvent("Events"); + event.initEvent("findbarclose", true, false); + this.dispatchEvent(event); + + // 'focusContent()' iterates over all listeners in the chrome + // process, so we need to call it from here. + this.browser.finder.focusContent(); + this.browser.finder.onFindbarClose(); + + this._cancelTimers(); + this._updateBrowserWithState(); + + this._findFailedString = null; + } + + clear() { + this.browser.finder.removeSelection(); + // Clear value and undo/redo transactions + this._findField.value = ""; + if (this._findField.editor) { + this._findField.editor.transactionManager.clear(); + } + this.toggleHighlight(false); + this._updateStatusUI(); + this._enableFindButtons(false); + } + + _dispatchKeypressEvent(target, fakeEvent) { + if (!target) { + return; + } + + // The event information comes from the child process. + let event = new target.ownerGlobal.KeyboardEvent( + fakeEvent.type, + fakeEvent + ); + target.dispatchEvent(event); + } + + _updateStatusUIBar(foundURL) { + if (!this._xulBrowserWindow) { + try { + this._xulBrowserWindow = window.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).XULBrowserWindow; + } catch (ex) {} + if (!this._xulBrowserWindow) { + return false; + } + } + + // Call this has the same effect like hovering over link, + // the browser shows the URL as a tooltip. + this._xulBrowserWindow.setOverLink(foundURL || ""); + return true; + } + + _finishFAYT(keypressEvent) { + this.browser.finder.focusContent(); + + if (keypressEvent) { + keypressEvent.preventDefault(); + } + + this.browser.finder.keyPress(keypressEvent); + + this.close(); + return true; + } + + _shouldBeCaseSensitive(str) { + if (this._typeAheadCaseSensitive == 0) { + return false; + } + if (this._typeAheadCaseSensitive == 1) { + return true; + } + + return str != str.toLowerCase(); + } + + _shouldMatchDiacritics(str) { + if (this._matchDiacritics == 0) { + return false; + } + if (this._matchDiacritics == 1) { + return true; + } + + return str != str.normalize("NFD"); + } + + onMouseUp() { + if (!this.hidden && this.findMode != this.FIND_NORMAL) { + this.close(); + } + } + + /** + * We get a fake event object through an IPC message when FAYT is being used + * from within the browser. We then stuff that input in the find bar here. + * + * @param {Object} fakeEvent Event object that looks and quacks like a + * native DOM KeyPress event. + */ + _onBrowserKeypress(fakeEvent) { + const FAYT_LINKS_KEY = "'"; + const FAYT_TEXT_KEY = "/"; + + if (!this.hidden && this._findField == document.activeElement) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + if (this.findMode != this.FIND_NORMAL && this._quickFindTimeout) { + this._findField.select(); + this._findField.focus(); + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + let key = fakeEvent.charCode + ? String.fromCharCode(fakeEvent.charCode) + : null; + let manualstartFAYT = + (key == FAYT_LINKS_KEY || key == FAYT_TEXT_KEY) && this._manualFAYT; + let autostartFAYT = + !manualstartFAYT && this._findAsYouType && key && key != " "; + if (manualstartFAYT || autostartFAYT) { + let mode = + key == FAYT_LINKS_KEY || (autostartFAYT && this._typeAheadLinksOnly) + ? this.FIND_LINKS + : this.FIND_TYPEAHEAD; + + // Clear bar first, so that when openFindBar() calls setCaseSensitivity() + // it doesn't get confused by a lingering value + this._findField.value = ""; + + this.open(mode); + this._setFindCloseTimeout(); + this._findField.select(); + this._findField.focus(); + + if (autostartFAYT) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + } else { + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + } + } + } + + _updateBrowserWithState() { + if (this._browser) { + this._browser.sendMessageToActor( + "Findbar:UpdateState", + { + findMode: this.findMode, + isOpenAndFocused: + !this.hidden && document.activeElement == this._findField, + hasQuickFindTimeout: !!this._quickFindTimeout, + }, + "FindBar", + "all" + ); + } + } + + _enableFindButtons(aEnable) { + this.getElement("find-next").disabled = this.getElement( + "find-previous" + ).disabled = !aEnable; + } + + /** + * Determines whether minimalist or general-purpose search UI is to be + * displayed when the find bar is activated. + */ + _updateFindUI() { + let showMinimalUI = this.findMode != this.FIND_NORMAL; + + let nodes = this.getElement("findbar-container").children; + let wrapper = this.getElement("findbar-textbox-wrapper"); + let foundMatches = this._foundMatches; + for (let node of nodes) { + if (node == wrapper || node == foundMatches) { + continue; + } + node.hidden = showMinimalUI; + } + this.getElement("find-next").hidden = this.getElement( + "find-previous" + ).hidden = showMinimalUI; + foundMatches.hidden = showMinimalUI || !foundMatches.value; + this._updateCaseSensitivity(); + this._updateDiacriticMatching(); + this._setEntireWord(); + this._setHighlightAll(); + + if (showMinimalUI) { + this._findField.classList.add("minimal"); + } else { + this._findField.classList.remove("minimal"); + } + + if (this.findMode == this.FIND_TYPEAHEAD) { + this._findField.placeholder = this._fastFindStr; + } else if (this.findMode == this.FIND_LINKS) { + this._findField.placeholder = this._fastFindLinksStr; + } else { + this._findField.placeholder = this._normalFindStr; + } + } + + _find(value) { + if (!this._dispatchFindEvent("")) { + return; + } + + let val = value || this._findField.value; + + // We have to carry around an explicit version of this, because + // finder.searchString doesn't update on failed searches. + this.browser._lastSearchString = val; + + // Only search on input if we don't have a last-failed string, + // or if the current search string doesn't start with it. + // In entire-word mode we always attemp a find; since sequential matching + // is not guaranteed, the first character typed may not be a word (no + // match), but the with the second character it may well be a word, + // thus a match. + if ( + !this._findFailedString || + !val.startsWith(this._findFailedString) || + this._entireWord + ) { + // Getting here means the user commanded a find op. Make sure any + // initial prefilling is ignored if it hasn't happened yet. + if (this._startFindDeferred) { + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + + this._enableFindButtons(val); + this._updateCaseSensitivity(val); + this._updateDiacriticMatching(val); + this._setEntireWord(); + + this.browser.finder.fastFind( + val, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + + if (this._findResetTimeout != -1) { + clearTimeout(this._findResetTimeout); + } + + // allow a search to happen on input again after a second has expired + // since the previous input, to allow for dynamic content and/ or page + // loading. + this._findResetTimeout = setTimeout(() => { + this._findFailedString = null; + this._findResetTimeout = -1; + }, 1000); + } + + _flash() { + if (this._flashFindBarCount === undefined) { + this._flashFindBarCount = this._initialFlashFindBarCount; + } + + if (this._flashFindBarCount-- == 0) { + clearInterval(this._flashFindBarTimeout); + this._findField.removeAttribute("flash"); + this._flashFindBarCount = 6; + return; + } + + this._findField.setAttribute( + "flash", + this._flashFindBarCount % 2 == 0 ? "false" : "true" + ); + } + + _findAgain(findPrevious) { + this.browser.finder.findAgain( + this._findField.value, + findPrevious, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + _updateStatusUI(res, findPrevious) { + switch (res) { + case Ci.nsITypeAheadFind.FIND_WRAPPED: + this._findStatusIcon.setAttribute("status", "wrapped"); + this._findStatusDesc.textContent = findPrevious + ? this._wrappedToBottomStr + : this._wrappedToTopStr; + this._findField.removeAttribute("status"); + break; + case Ci.nsITypeAheadFind.FIND_NOTFOUND: + this._findStatusIcon.setAttribute("status", "notfound"); + this._findStatusDesc.textContent = this._notFoundStr; + this._findField.setAttribute("status", "notfound"); + break; + case Ci.nsITypeAheadFind.FIND_PENDING: + this._findStatusIcon.setAttribute("status", "pending"); + this._findStatusDesc.textContent = ""; + this._findField.removeAttribute("status"); + break; + case Ci.nsITypeAheadFind.FIND_FOUND: + default: + this._findStatusIcon.removeAttribute("status"); + this._findStatusDesc.textContent = ""; + this._findField.removeAttribute("status"); + break; + } + } + + updateControlState(result, findPrevious) { + this._updateStatusUI(result, findPrevious); + this._enableFindButtons( + result !== Ci.nsITypeAheadFind.FIND_NOTFOUND && !!this._findField.value + ); + } + + _dispatchFindEvent(type, findPrevious) { + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("find" + type, true, true, { + query: this._findField.value, + caseSensitive: !!this._typeAheadCaseSensitive, + matchDiacritics: !!this._matchDiacritics, + entireWord: this._entireWord, + highlightAll: this._highlightAll, + findPrevious, + }); + return this.dispatchEvent(event); + } + + /** + * Opens the findbar, focuses the findfield and selects its contents. + * Also flashes the findbar the first time it's used. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Promise} A promise that will be resolved when the findbar is + * fully opened. + */ + startFind(mode) { + let prefsvc = Services.prefs; + let userWantsPrefill = true; + this.open(mode); + + if (this._flashFindBar) { + this._flashFindBarTimeout = setInterval(() => this._flash(), 500); + prefsvc.setIntPref( + "accessibility.typeaheadfind.flashBar", + --this._flashFindBar + ); + } + + let { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" + ); + this._startFindDeferred = PromiseUtils.defer(); + let startFindPromise = this._startFindDeferred.promise; + + if (this.prefillWithSelection) { + userWantsPrefill = prefsvc.getBoolPref( + "accessibility.typeaheadfind.prefillwithselection" + ); + } + + if (this.prefillWithSelection && userWantsPrefill) { + this.browser.finder.getInitialSelection(); + + // NB: We have to focus this._findField here so tests that send + // key events can open and close the find bar synchronously. + this._findField.focus(); + + // (e10s) since we focus lets also select it, otherwise that would + // only happen in this.onCurrentSelection and, because it is async, + // there's a chance keypresses could come inbetween, leading to + // jumbled up queries. + this._findField.select(); + + return startFindPromise; + } + + // If userWantsPrefill is false but prefillWithSelection is true, + // then we might need to check the selection clipboard. Call + // onCurrentSelection to do so. + // Note: this.onCurrentSelection clears this._startFindDeferred. + this.onCurrentSelection("", true); + return startFindPromise; + } + + /** + * Convenient alias to startFind(gFindBar.FIND_NORMAL); + * + * You should generally map the window's find command to this method. + * e.g. <command name="cmd_find" oncommand="gFindBar.onFindCommand();"/> + */ + onFindCommand() { + return this.startFind(this.FIND_NORMAL); + } + + /** + * Stub for find-next and find-previous commands. + * + * @param {Boolean} findPrevious `true` for find-previous, `false` + * otherwise. + */ + onFindAgainCommand(findPrevious) { + if (findPrevious) { + Services.telemetry.scalarAdd("findbar.find_prev", 1); + } else { + Services.telemetry.scalarAdd("findbar.find_next", 1); + } + + let findString = + this._browser.finder.searchString || this._findField.value; + if (!findString) { + return this.startFind(); + } + + // We dispatch the findAgain event here instead of in _findAgain since + // if there is a find event handler that prevents the default then + // finder.searchString will never get updated which in turn means + // there would never be findAgain events because of the logic below. + if (!this._dispatchFindEvent("again", findPrevious)) { + return undefined; + } + + // user explicitly requested another search, so do it even if we think it'll fail + this._findFailedString = null; + + // Ensure the stored SearchString is in sync with what we want to find + if (this._findField.value != this._browser.finder.searchString) { + this._find(this._findField.value); + } else { + this._findAgain(findPrevious); + if (this._useModalHighlight) { + this.open(); + this._findField.focus(); + } + } + + return undefined; + } + + /** + * Fetches the currently selected text and sets that as the text to search + * next. This is a MacOS specific feature. + */ + onFindSelectionCommand() { + this.browser.finder.setSearchStringToSelection().then(searchInfo => { + if (searchInfo.selectedText) { + this._findField.value = searchInfo.selectedText; + } + }); + } + + _onAppActivateMac() { + const kPref = "accessibility.typeaheadfind.prefillwithselection"; + if (this.prefillWithSelection && Services.prefs.getBoolPref(kPref)) { + return; + } + + let clipboardSearchString = this._browser.finder.clipboardSearchString; + if ( + clipboardSearchString && + this._findField.value != clipboardSearchString && + !this._findField._willfullyDeleted + ) { + this._findField.value = clipboardSearchString; + this._findField._hadValue = true; + // Changing the search string makes the previous status invalid, so + // we better clear it here. + this._updateStatusUI(); + } + } + + /** + * This handles all the result changes for both type-ahead-find and + * highlighting. + * + * @param {Object} data A dictionary that holds the following properties: + * - {Number} result One of the FIND_* constants + * indicating the result of a search + * operation. + * - {Boolean} findBackwards If the search was done + * from the bottom to the + * top. This is used for + * status messages when + * reaching "the end of the + * page". + * - {String} linkURL When a link matched, then its + * URL. Always null when not in + * FIND_LINKS mode. + */ + onFindResult(data) { + if (data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND) { + // If an explicit Find Again command fails, re-open the toolbar. + if (data.storeResult && this.open()) { + this._findField.select(); + this._findField.focus(); + } + this._findFailedString = data.searchString; + } else { + this._findFailedString = null; + } + + this._updateStatusUI(data.result, data.findBackwards); + this._updateStatusUIBar(data.linkURL); + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + } + + /** + * This handles all the result changes for matches counts. + * + * @param {Object} result Result Object, containing the total amount of + * matches and a vector of the current result. + * - {Number} total Total count number of matches found. + * - {Number} limit Current setting of the number of matches + * to hit to hit the limit. + * - {Number} current Vector of the current result. + */ + onMatchesCountResult(result) { + if (result.total !== 0) { + if (result.total == -1) { + this._foundMatches.value = LazyConstants.PluralForm.get( + result.limit, + this.strBundle.GetStringFromName("FoundMatchesCountLimit") + ).replace("#1", result.limit); + } else { + this._foundMatches.value = LazyConstants.PluralForm.get( + result.total, + this.strBundle.GetStringFromName("FoundMatches") + ) + .replace("#1", result.current) + .replace("#2", result.total); + } + this._foundMatches.hidden = false; + } else { + this._foundMatches.hidden = true; + this._foundMatches.value = ""; + } + } + + onHighlightFinished(result) { + // Noop. + } + + onCurrentSelection(selectionString, isInitialSelection) { + // Ignore the prefill if the user has already typed in the findbar, + // it would have been overwritten anyway. See bug 1198465. + if (isInitialSelection && !this._startFindDeferred) { + return; + } + + if ( + AppConstants.platform == "macosx" && + isInitialSelection && + !selectionString + ) { + let clipboardSearchString = this.browser.finder.clipboardSearchString; + if (clipboardSearchString) { + selectionString = clipboardSearchString; + } + } + + if (selectionString) { + this._findField.value = selectionString; + } + + if (isInitialSelection) { + this._enableFindButtons(!!this._findField.value); + this._findField.select(); + this._findField.focus(); + + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + } + + /** + * This handler may cancel a request to focus content by returning |false| + * explicitly. + */ + shouldFocusContent() { + const fm = Services.focus; + if (fm.focusedWindow != window) { + return false; + } + + let focusedElement = fm.focusedElement; + if (!focusedElement) { + return false; + } + + let focusedParent = focusedElement.closest("findbar"); + if (focusedParent != this && focusedParent != this._findField) { + return false; + } + + return true; + } + + disconnectedCallback() { + // Empty the DOM. We will rebuild if reconnected. + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this.destroy(); + } + } + + customElements.define("findbar", MozFindbar); +} diff --git a/toolkit/content/widgets/general.js b/toolkit/content/widgets/general.js new file mode 100644 index 0000000000..41ce769f4d --- /dev/null +++ b/toolkit/content/widgets/general.js @@ -0,0 +1,79 @@ +/* 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 is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + class MozDeck extends MozXULElement { + set selectedIndex(val) { + if (this.selectedIndex == val) { + return val; + } + this.setAttribute("selectedIndex", val); + var event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + return val; + } + + get selectedIndex() { + return this.getAttribute("selectedIndex") || "0"; + } + + set selectedPanel(val) { + var selectedIndex = -1; + for ( + var panel = val; + panel != null; + panel = panel.previousElementSibling + ) { + ++selectedIndex; + } + this.selectedIndex = selectedIndex; + return val; + } + + get selectedPanel() { + return this.children[this.selectedIndex]; + } + } + + customElements.define("deck", MozDeck); + + class MozDropmarker extends MozXULElement { + constructor() { + super(); + let shadowRoot = this.attachShadow({ mode: "open" }); + let stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "chrome://global/skin/dropmarker.css"; + + let image = document.createXULElement("image"); + image.part = "icon"; + shadowRoot.append(stylesheet, image); + } + } + + customElements.define("dropmarker", MozDropmarker); + + class MozCommandSet extends MozXULElement { + connectedCallback() { + if (this.getAttribute("commandupdater") === "true") { + const events = this.getAttribute("events") || "*"; + const targets = this.getAttribute("targets") || "*"; + document.commandDispatcher.addCommandUpdater(this, events, targets); + } + } + + disconnectedCallback() { + if (this.getAttribute("commandupdater") === "true") { + document.commandDispatcher.removeCommandUpdater(this); + } + } + } + + customElements.define("commandset", MozCommandSet); +} diff --git a/toolkit/content/widgets/marquee.css b/toolkit/content/widgets/marquee.css new file mode 100644 index 0000000000..b898cd0dce --- /dev/null +++ b/toolkit/content/widgets/marquee.css @@ -0,0 +1,26 @@ +/* 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/. */ + +.outerDiv { + overflow: hidden; + width: -moz-available; +} + +.horizontal > .innerDiv { + width: max-content; + /* We want to create overflow of twice our available space. */ + padding: 0 100%; +} + +/* disable scrolling in contenteditable */ +:host(:read-write) .innerDiv { + padding: 0 !important; +} + +/* When printing or when the user doesn't want movement, we disable scrolling */ +@media print, (prefers-reduced-motion) { + .innerDiv { + padding: 0 !important; + } +} diff --git a/toolkit/content/widgets/marquee.js b/toolkit/content/widgets/marquee.js new file mode 100644 index 0000000000..34e9323ec7 --- /dev/null +++ b/toolkit/content/widgets/marquee.js @@ -0,0 +1,417 @@ +/* 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 is the class of entry. It will construct the actual implementation + * according to the value of the "direction" property. + */ +this.MarqueeWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgetsChild wheen the direction property + * changes. + */ + onchange() { + this.switchImpl(); + } + + switchImpl() { + let newImpl; + switch (this.element.direction) { + case "up": + case "down": + newImpl = MarqueeVerticalImplWidget; + break; + case "left": + case "right": + newImpl = MarqueeHorizontalImplWidget; + break; + } + + // Skip if we are asked to load the same implementation. + // This can happen if the property is set again w/o value change. + if (this.impl && this.impl.constructor == newImpl) { + return; + } + this.destructor(); + if (newImpl) { + this.impl = new newImpl(this.shadowRoot); + this.impl.onsetup(); + } + } + + destructor() { + if (!this.impl) { + return; + } + this.impl.destructor(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } +}; + +this.MarqueeBaseImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup() { + this.generateContent(); + + // Set up state. + this._currentDirection = this.element.direction || "left"; + this._currentLoop = this.element.loop; + this.dirsign = 1; + this.startAt = 0; + this.stopAt = 0; + this.newPosition = 0; + this.runId = 0; + this.originalHeight = 0; + this.invalidateCache = true; + + this._mutationObserver = new this.window.MutationObserver(aMutations => + this._mutationActor(aMutations) + ); + this._mutationObserver.observe(this.element, { + attributes: true, + attributeOldValue: true, + attributeFilter: ["loop", "", "behavior", "direction", "width", "height"], + }); + + // init needs to be run after the page has loaded in order to calculate + // the correct height/width + if (this.document.readyState == "complete") { + this.init(); + } else { + this.window.addEventListener("load", this, { once: true }); + } + + this.shadowRoot.addEventListener("marquee-start", this); + this.shadowRoot.addEventListener("marquee-stop", this); + } + + destructor() { + this._mutationObserver.disconnect(); + this.window.clearTimeout(this.runId); + + this.window.removeEventListener("load", this); + this.shadowRoot.removeEventListener("marquee-start", this); + this.shadowRoot.removeEventListener("marquee-stop", this); + } + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + switch (aEvent.type) { + case "load": + this.init(); + break; + case "marquee-start": + this.doStart(); + break; + case "marquee-stop": + this.doStop(); + break; + } + } + + get outerDiv() { + return this.shadowRoot.firstChild; + } + + get innerDiv() { + return this.shadowRoot.getElementById("innerDiv"); + } + + get scrollDelayWithTruespeed() { + if (this.element.scrollDelay < 60 && !this.element.trueSpeed) { + return 60; + } + return this.element.scrollDelay; + } + + doStart() { + if (this.runId == 0) { + var lambda = () => this._doMove(false); + this.runId = this.window.setTimeout( + lambda, + this.scrollDelayWithTruespeed - this._deltaStartStop + ); + this._deltaStartStop = 0; + } + } + + doStop() { + if (this.runId != 0) { + this._deltaStartStop = Date.now() - this._lastMoveDate; + this.window.clearTimeout(this.runId); + } + + this.runId = 0; + } + + _fireEvent(aName, aBubbles, aCancelable) { + var e = this.document.createEvent("Events"); + e.initEvent(aName, aBubbles, aCancelable); + this.element.dispatchEvent(e); + } + + _doMove(aResetPosition) { + this._lastMoveDate = Date.now(); + + // invalidateCache is true at first load and whenever an attribute + // is changed + if (this.invalidateCache) { + this.invalidateCache = false; // we only want this to run once every scroll direction change + + var corrvalue = 0; + + switch (this._currentDirection) { + case "up": + case "down": { + let height = this.window.getComputedStyle(this.element).height; + this.outerDiv.style.height = height; + if (this.originalHeight > this.outerDiv.offsetHeight) { + corrvalue = this.originalHeight - this.outerDiv.offsetHeight; + } + this.innerDiv.style.padding = height + " 0"; + let isUp = this._currentDirection == "up"; + if (isUp) { + this.dirsign = 1; + this.startAt = + this.element.behavior == "alternate" + ? this.originalHeight - corrvalue + : 0; + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? parseInt(height) + corrvalue + : this.originalHeight + parseInt(height); + } else { + this.dirsign = -1; + this.startAt = + this.element.behavior == "alternate" + ? parseInt(height) + corrvalue + : this.originalHeight + parseInt(height); + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? this.originalHeight - corrvalue + : 0; + } + break; + } + case "left": + case "right": + default: { + let isRight = this._currentDirection == "right"; + // NOTE: It's important to use getComputedStyle() to not account for the padding. + let innerWidth = parseInt( + this.window.getComputedStyle(this.innerDiv).width + ); + if (innerWidth > this.outerDiv.offsetWidth) { + corrvalue = innerWidth - this.outerDiv.offsetWidth; + } + let rtl = + this.window.getComputedStyle(this.element).direction == "rtl"; + if (isRight != rtl) { + this.dirsign = -1; + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? innerWidth - corrvalue + : 0; + this.startAt = + this.outerDiv.offsetWidth + + (this.element.behavior == "alternate" + ? corrvalue + : innerWidth + this.stopAt); + } else { + this.dirsign = 1; + this.startAt = + this.element.behavior == "alternate" ? innerWidth - corrvalue : 0; + this.stopAt = + this.outerDiv.offsetWidth + + (this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? corrvalue + : innerWidth + this.startAt); + } + if (rtl) { + this.startAt = -this.startAt; + this.stopAt = -this.stopAt; + this.dirsign = -this.dirsign; + } + break; + } + } + + if (aResetPosition) { + this.newPosition = this.startAt; + this._fireEvent("start", false, false); + } + } // end if + + this.newPosition = + this.newPosition + this.dirsign * this.element.scrollAmount; + + if ( + (this.dirsign == 1 && this.newPosition > this.stopAt) || + (this.dirsign == -1 && this.newPosition < this.stopAt) + ) { + switch (this.element.behavior) { + case "alternate": + // lets start afresh + this.invalidateCache = true; + + // swap direction + const swap = { left: "right", down: "up", up: "down", right: "left" }; + this._currentDirection = swap[this._currentDirection] || "left"; + this.newPosition = this.stopAt; + + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + if (this._currentLoop != 1) { + this._fireEvent("bounce", false, true); + } + break; + + case "slide": + if (this._currentLoop > 1) { + this.newPosition = this.startAt; + } + break; + + default: + this.newPosition = this.startAt; + + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + // dispatch start event, even when this._currentLoop == 1, comp. with IE6 + this._fireEvent("start", false, false); + } + + if (this._currentLoop > 1) { + this._currentLoop--; + } else if (this._currentLoop == 1) { + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.stopAt; + } else { + this.outerDiv.scrollLeft = this.stopAt; + } + this.element.stop(); + this._fireEvent("finish", false, true); + return; + } + } else if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + var myThis = this; + var lambda = function myTimeOutFunction() { + myThis._doMove(false); + }; + this.runId = this.window.setTimeout(lambda, this.scrollDelayWithTruespeed); + } + + init() { + this.element.stop(); + + if (this._currentDirection == "up" || this._currentDirection == "down") { + // store the original height before we add padding + this.innerDiv.style.padding = 0; + this.originalHeight = this.innerDiv.offsetHeight; + } + + this._doMove(true); + } + + _mutationActor(aMutations) { + while (aMutations.length) { + var mutation = aMutations.shift(); + var attrName = mutation.attributeName.toLowerCase(); + var oldValue = mutation.oldValue; + var target = mutation.target; + var newValue = target.getAttribute(attrName); + + if (oldValue != newValue) { + this.invalidateCache = true; + switch (attrName) { + case "loop": + this._currentLoop = target.loop; + break; + case "direction": + this._currentDirection = target.direction; + break; + } + } + } + } +}; + +this.MarqueeHorizontalImplWidget = class extends MarqueeBaseImplWidget { + generateContent() { + // White-space isn't allowed because a marquee could be + // inside 'white-space: pre' + this.shadowRoot.innerHTML = `<div class="outerDiv horizontal" + ><link rel="stylesheet" href="chrome://global/content/elements/marquee.css" + /><div class="innerDiv" id="innerDiv" + ><slot + /></div + ></div>`; + } +}; + +this.MarqueeVerticalImplWidget = class extends MarqueeBaseImplWidget { + generateContent() { + // White-space isn't allowed because a marquee could be + // inside 'white-space: pre' + this.shadowRoot.innerHTML = `<div class="outerDiv vertical" + ><link rel="stylesheet" href="chrome://global/content/elements/marquee.css" + /><div class="innerDiv" id="innerDiv" + ><slot + /></div + ></div>`; + } +}; diff --git a/toolkit/content/widgets/menu.js b/toolkit/content/widgets/menu.js new file mode 100644 index 0000000000..07869317bb --- /dev/null +++ b/toolkit/content/widgets/menu.js @@ -0,0 +1,492 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + let imports = {}; + ChromeUtils.defineModuleGetter( + imports, + "ShortcutUtils", + "resource://gre/modules/ShortcutUtils.jsm" + ); + + const MozMenuItemBaseMixin = Base => { + class MozMenuItemBase extends MozElements.BaseTextMixin(Base) { + // nsIDOMXULSelectControlItemElement + set value(val) { + this.setAttribute("value", val); + } + get value() { + return this.getAttribute("value"); + } + + // nsIDOMXULSelectControlItemElement + get selected() { + return this.getAttribute("selected") == "true"; + } + + // nsIDOMXULSelectControlItemElement + get control() { + var parent = this.parentNode; + // Return the parent if it is a menu or menulist. + if (parent && parent.parentNode instanceof XULMenuElement) { + return parent.parentNode; + } + return null; + } + + // nsIDOMXULContainerItemElement + get parentContainer() { + for (var parent = this.parentNode; parent; parent = parent.parentNode) { + if (parent instanceof XULMenuElement) { + return parent; + } + } + return null; + } + } + MozXULElement.implementCustomInterface(MozMenuItemBase, [ + Ci.nsIDOMXULSelectControlItemElement, + Ci.nsIDOMXULContainerItemElement, + ]); + return MozMenuItemBase; + }; + + const MozMenuBaseMixin = Base => { + class MozMenuBase extends MozMenuItemBaseMixin(Base) { + set open(val) { + this.openMenu(val); + return val; + } + + get open() { + return this.hasAttribute("open"); + } + + get itemCount() { + var menupopup = this.menupopup; + return menupopup ? menupopup.children.length : 0; + } + + get menupopup() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + for ( + var child = this.firstElementChild; + child; + child = child.nextElementSibling + ) { + if (child.namespaceURI == XUL_NS && child.localName == "menupopup") { + return child; + } + } + return null; + } + + appendItem(aLabel, aValue) { + var menupopup = this.menupopup; + if (!menupopup) { + menupopup = this.ownerDocument.createXULElement("menupopup"); + this.appendChild(menupopup); + } + + var menuitem = this.ownerDocument.createXULElement("menuitem"); + menuitem.setAttribute("label", aLabel); + menuitem.setAttribute("value", aValue); + + return menupopup.appendChild(menuitem); + } + + getIndexOfItem(aItem) { + var menupopup = this.menupopup; + if (menupopup) { + var items = menupopup.children; + var length = items.length; + for (var index = 0; index < length; ++index) { + if (items[index] == aItem) { + return index; + } + } + } + return -1; + } + + getItemAtIndex(aIndex) { + var menupopup = this.menupopup; + if (!menupopup || aIndex < 0 || aIndex >= menupopup.children.length) { + return null; + } + + return menupopup.children[aIndex]; + } + } + MozXULElement.implementCustomInterface(MozMenuBase, [ + Ci.nsIDOMXULContainerElement, + ]); + return MozMenuBase; + }; + + // The <menucaption> element is used for rendering <html:optgroup> inside of <html:select>, + // See SelectParentHelper.jsm. + class MozMenuCaption extends MozMenuBaseMixin(MozXULElement) { + static get inheritedAttributes() { + return { + ".menu-iconic-left": "selected,disabled,checked", + ".menu-iconic-icon": "src=image,validate,src", + ".menu-iconic-text": "value=label,crop,highlightable", + ".menu-iconic-highlightable-text": "text=label,crop,highlightable", + }; + } + + connectedCallback() { + this.textContent = ""; + this.appendChild( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon" aria-hidden="true"></image> + </hbox> + <label class="menu-iconic-text" flex="1" crop="right" aria-hidden="true"></label> + <label class="menu-iconic-highlightable-text" crop="right" aria-hidden="true"></label> + `) + ); + this.initializeAttributeInheritance(); + } + } + + customElements.define("menucaption", MozMenuCaption); + + // In general, wait to render menus and menuitems inside menupopups + // until they are going to be visible: + window.addEventListener( + "popupshowing", + e => { + if (e.originalTarget.ownerDocument != document) { + return; + } + e.originalTarget.setAttribute("hasbeenopened", "true"); + for (let el of e.originalTarget.querySelectorAll("menuitem, menu")) { + el.render(); + } + }, + { capture: true } + ); + + class MozMenuItem extends MozMenuItemBaseMixin(MozXULElement) { + static get observedAttributes() { + return super.observedAttributes.concat("acceltext", "key"); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "acceltext") { + if (this._ignoreAccelTextChange) { + this._ignoreAccelTextChange = false; + } else { + this._accelTextIsDerived = false; + this._computeAccelTextFromKeyIfNeeded(); + } + } + if (name == "key") { + this._computeAccelTextFromKeyIfNeeded(); + } + super.attributeChangedCallback(name, oldValue, newValue); + } + + static get inheritedAttributes() { + return { + ".menu-iconic-text": "value=label,crop,accesskey,highlightable", + ".menu-text": "value=label,crop,accesskey,highlightable", + ".menu-iconic-highlightable-text": + "text=label,crop,accesskey,highlightable", + ".menu-iconic-left": "selected,_moz-menuactive,disabled,checked", + ".menu-iconic-icon": + "src=image,validate,triggeringprincipal=iconloadingprincipal", + ".menu-iconic-accel": "value=acceltext", + ".menu-accel": "value=acceltext", + }; + } + + static get iconicNoAccelFragment() { + // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here. + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="right" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="right" aria-hidden="true"/> + `), + true + ); + Object.defineProperty(this, "iconicNoAccelFragment", { value: frag }); + return frag; + } + + static get iconicFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="right" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="right" aria-hidden="true"/> + <hbox class="menu-accel-container" aria-hidden="true"> + <label class="menu-iconic-accel"/> + </hbox> + `), + true + ); + Object.defineProperty(this, "iconicFragment", { value: frag }); + return frag; + } + + static get plainFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <label class="menu-text" crop="right" aria-hidden="true"/> + <hbox class="menu-accel-container" aria-hidden="true"> + <label class="menu-accel"/> + </hbox> + `), + true + ); + Object.defineProperty(this, "plainFragment", { value: frag }); + return frag; + } + + get isIconic() { + let type = this.getAttribute("type"); + return ( + type == "checkbox" || + type == "radio" || + this.classList.contains("menuitem-iconic") + ); + } + + get isMenulistChild() { + return this.matches("menulist > menupopup > menuitem"); + } + + get isInHiddenMenupopup() { + return this.matches("menupopup:not([hasbeenopened]) menuitem"); + } + + _computeAccelTextFromKeyIfNeeded() { + if (!this._accelTextIsDerived && this.getAttribute("acceltext")) { + return; + } + let accelText = (() => { + if (!document.contains(this)) { + return null; + } + let keyId = this.getAttribute("key"); + if (!keyId) { + return null; + } + let key = document.getElementById(keyId); + if (!key) { + Cu.reportError( + `Key ${keyId} of menuitem ${this.getAttribute("label")} ` + + `could not be found` + ); + return null; + } + return imports.ShortcutUtils.prettifyShortcut(key); + })(); + + this._accelTextIsDerived = true; + // We need to ignore the next attribute change callback for acceltext, in + // order to not reenter here. + this._ignoreAccelTextChange = true; + if (accelText) { + this.setAttribute("acceltext", accelText); + } else { + this.removeAttribute("acceltext"); + } + } + + render() { + if (this.renderedOnce) { + return; + } + this.renderedOnce = true; + this.textContent = ""; + if (this.isMenulistChild) { + this.append(this.constructor.iconicNoAccelFragment.cloneNode(true)); + } else if (this.isIconic) { + this.append(this.constructor.iconicFragment.cloneNode(true)); + } else { + this.append(this.constructor.plainFragment.cloneNode(true)); + } + + this._computeAccelTextFromKeyIfNeeded(); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + if (this.renderedOnce) { + this._computeAccelTextFromKeyIfNeeded(); + } + // Eagerly render if we are being inserted into a menulist (since we likely need to + // size it), or into an already-opened menupopup (since we are already visible). + // Checking isConnectedAndReady is an optimization that will let us quickly skip + // non-menulists that are being connected during parse. + if ( + this.isMenulistChild || + (this.isConnectedAndReady && !this.isInHiddenMenupopup) + ) { + this.render(); + } + } + } + + customElements.define("menuitem", MozMenuItem); + + const isHiddenWindow = + document.documentURI == "chrome://browser/content/hiddenWindowMac.xhtml"; + + class MozMenu extends MozMenuBaseMixin( + MozElements.MozElementMixin(XULMenuElement) + ) { + static get inheritedAttributes() { + return { + ".menubar-text": "value=label,accesskey,crop", + ".menu-iconic-text": "value=label,accesskey,crop,highlightable", + ".menu-text": "value=label,accesskey,crop", + ".menu-iconic-highlightable-text": + "text=label,crop,accesskey,highlightable", + ".menubar-left": "src=image", + ".menu-iconic-icon": + "src=image,triggeringprincipal=iconloadingprincipal,validate", + ".menu-iconic-accel": "value=acceltext", + ".menu-right": "_moz-menuactive,disabled", + ".menu-accel": "value=acceltext", + }; + } + + get needsEagerRender() { + return ( + this.isMenubarChild || this.isMenulistChild || !this.isInHiddenMenupopup + ); + } + + get isMenubarChild() { + return this.matches("menubar > menu"); + } + + get isMenulistChild() { + return this.matches("menulist > menupopup > menu"); + } + + get isInHiddenMenupopup() { + return this.matches("menupopup:not([hasbeenopened]) menu"); + } + + get isIconic() { + return this.classList.contains("menu-iconic"); + } + + get fragment() { + let { isMenubarChild, isIconic } = this; + let fragment = null; + // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here. + if (isMenubarChild && isIconic) { + if (!MozMenu.menubarIconicFrag) { + MozMenu.menubarIconicFrag = MozXULElement.parseXULToFragment(` + <image class="menubar-left" aria-hidden="true"/> + <label class="menubar-text" crop="right" aria-hidden="true"/> + `); + } + fragment = document.importNode(MozMenu.menubarIconicFrag, true); + } + if (isMenubarChild && !isIconic) { + if (!MozMenu.menubarFrag) { + MozMenu.menubarFrag = MozXULElement.parseXULToFragment(` + <label class="menubar-text" crop="right" aria-hidden="true"/> + `); + } + fragment = document.importNode(MozMenu.menubarFrag, true); + } + if (!isMenubarChild && isIconic) { + if (!MozMenu.normalIconicFrag) { + MozMenu.normalIconicFrag = MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="right" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="right" aria-hidden="true"/> + <hbox class="menu-accel-container" anonid="accel" aria-hidden="true"> + <label class="menu-iconic-accel"/> + </hbox> + <hbox align="center" class="menu-right" aria-hidden="true"> + <image/> + </hbox> + `); + } + + fragment = document.importNode(MozMenu.normalIconicFrag, true); + } + if (!isMenubarChild && !isIconic) { + if (!MozMenu.normalFrag) { + MozMenu.normalFrag = MozXULElement.parseXULToFragment(` + <label class="menu-text" crop="right" aria-hidden="true"/> + <hbox class="menu-accel-container" anonid="accel" aria-hidden="true"> + <label class="menu-accel"/> + </hbox> + <hbox align="center" class="menu-right" aria-hidden="true"> + <image/> + </hbox> + `); + } + + fragment = document.importNode(MozMenu.normalFrag, true); + } + return fragment; + } + + render() { + // There are 2 main types of menus: + // (1) direct descendant of a menubar + // (2) all other menus + // There is also an "iconic" variation of (1) and (2) based on the class. + // To make this as simple as possible, we don't support menus being changed from one + // of these types to another after the initial DOM connection. It'd be possible to make + // this work by keeping track of the markup we prepend and then removing / re-prepending + // during a change, but it's not a feature we use anywhere currently. + if (this.renderedOnce) { + return; + } + this.renderedOnce = true; + + // There will be a <menupopup /> already. Don't clear it out, just put our markup before it. + this.prepend(this.fragment); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + // On OSX we will have a bunch of menus in the hidden window. They get converted + // into native menus based on the host attributes, so the inner DOM doesn't need + // to be created. + if (isHiddenWindow) { + return; + } + + if (this.delayConnectedCallback()) { + return; + } + + // Wait until we are going to be visible or required for sizing a popup. + if (!this.needsEagerRender) { + return; + } + + this.render(); + } + } + + customElements.define("menu", MozMenu); +} diff --git a/toolkit/content/widgets/menulist.js b/toolkit/content/widgets/menulist.js new file mode 100644 index 0000000000..e4565824c2 --- /dev/null +++ b/toolkit/content/widgets/menulist.js @@ -0,0 +1,416 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const MozXULMenuElement = MozElements.MozElementMixin(XULMenuElement); + const MenuBaseControl = MozElements.BaseControlMixin(MozXULMenuElement); + + class MozMenuList extends MenuBaseControl { + constructor() { + super(); + + this.addEventListener( + "command", + event => { + if (event.target.parentNode.parentNode == this) { + this.selectedItem = event.target; + } + }, + true + ); + + this.addEventListener("popupshowing", event => { + if (event.target.parentNode == this) { + this.activeChild = null; + if (this.selectedItem) { + // Not ready for auto-setting the active child in hierarchies yet. + // For now, only do this when the outermost menupopup opens. + this.activeChild = this.mSelectedInternal; + } + } + }); + + this.addEventListener( + "keypress", + event => { + if (event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + if ( + !event.defaultPrevented && + (event.keyCode == KeyEvent.DOM_VK_UP || + event.keyCode == KeyEvent.DOM_VK_DOWN || + event.keyCode == KeyEvent.DOM_VK_PAGE_UP || + event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN || + event.keyCode == KeyEvent.DOM_VK_HOME || + event.keyCode == KeyEvent.DOM_VK_END || + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE || + event.charCode > 0) + ) { + // Moving relative to an item: start from the currently selected item + this.activeChild = this.mSelectedInternal; + if (this.handleKeyPress(event)) { + this.activeChild.doCommand(); + event.preventDefault(); + } + } + }, + { mozSystemGroup: true } + ); + + this.attachShadow({ mode: "open" }); + } + + static get inheritedAttributes() { + return { + image: "src=image", + "#label": "value=label,crop,accesskey,highlightable", + "#highlightable-label": "text=label,crop,accesskey,highlightable", + dropmarker: "disabled,open", + }; + } + + static get markup() { + // Accessibility information of these nodes will be presented + // on XULComboboxAccessible generated from <menulist>; + // hide these nodes from the accessibility tree. + return ` + <html:link href="chrome://global/skin/menulist.css" rel="stylesheet"/> + <hbox id="label-box" part="label-box" flex="1" role="none"> + <image part="icon" role="none"/> + <label id="label" part="label" crop="right" flex="1" role="none"/> + <label id="highlightable-label" part="label" crop="right" flex="1" role="none"/> + </hbox> + <dropmarker part="dropmarker" exportparts="icon: dropmarker-icon" type="menu" role="none"/> + <html:slot/> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + if (!this.hasAttribute("popuponly")) { + this.shadowRoot.appendChild(this.constructor.fragment); + this._labelBox = this.shadowRoot.getElementById("label-box"); + this._dropmarker = this.shadowRoot.querySelector("dropmarker"); + this.initializeAttributeInheritance(); + } else { + this.shadowRoot.appendChild(document.createElement("slot")); + } + + this.mSelectedInternal = null; + this.mAttributeObserver = null; + this.setInitialSelection(); + } + + // nsIDOMXULSelectControlElement + set value(val) { + // if the new value is null, we still need to remove the old value + if (val == null) { + return (this.selectedItem = val); + } + + var arr = null; + var popup = this.menupopup; + if (popup) { + arr = popup.getElementsByAttribute("value", val); + } + + if (arr && arr.item(0)) { + this.selectedItem = arr[0]; + } else { + this.selectedItem = null; + this.setAttribute("value", val); + } + + return val; + } + + // nsIDOMXULSelectControlElement + get value() { + return this.getAttribute("value"); + } + + // nsIDOMXULMenuListElement + set crop(val) { + this.setAttribute("crop", val); + } + + // nsIDOMXULMenuListElement + get crop() { + return this.getAttribute("crop"); + } + + // nsIDOMXULMenuListElement + set image(val) { + this.setAttribute("image", val); + return val; + } + + // nsIDOMXULMenuListElement + get image() { + return this.getAttribute("image"); + } + + // nsIDOMXULMenuListElement + get label() { + return this.getAttribute("label"); + } + + set description(val) { + this.setAttribute("description", val); + return val; + } + + get description() { + return this.getAttribute("description"); + } + + // nsIDOMXULMenuListElement + set open(val) { + this.openMenu(val); + return val; + } + + // nsIDOMXULMenuListElement + get open() { + return this.hasAttribute("open"); + } + + // nsIDOMXULSelectControlElement + get itemCount() { + return this.menupopup ? this.menupopup.children.length : 0; + } + + get menupopup() { + var popup = this.firstElementChild; + while (popup && popup.localName != "menupopup") { + popup = popup.nextElementSibling; + } + return popup; + } + + // nsIDOMXULSelectControlElement + set selectedIndex(val) { + var popup = this.menupopup; + if (popup && 0 <= val) { + if (val < popup.children.length) { + this.selectedItem = popup.children[val]; + } + } else { + this.selectedItem = null; + } + return val; + } + + // nsIDOMXULSelectControlElement + get selectedIndex() { + // Quick and dirty. We won't deal with hierarchical menulists yet. + if ( + !this.selectedItem || + !this.mSelectedInternal.parentNode || + this.mSelectedInternal.parentNode.parentNode != this + ) { + return -1; + } + + var children = this.mSelectedInternal.parentNode.children; + var i = children.length; + while (i--) { + if (children[i] == this.mSelectedInternal) { + break; + } + } + + return i; + } + + // nsIDOMXULSelectControlElement + set selectedItem(val) { + var oldval = this.mSelectedInternal; + if (oldval == val) { + return val; + } + + if (val && !this.contains(val)) { + return val; + } + + if (oldval) { + oldval.removeAttribute("selected"); + this.mAttributeObserver.disconnect(); + } + + this.mSelectedInternal = val; + let attributeFilter = ["value", "label", "image", "description"]; + if (val) { + val.setAttribute("selected", "true"); + for (let attr of attributeFilter) { + if (val.hasAttribute(attr)) { + this.setAttribute(attr, val.getAttribute(attr)); + } else { + this.removeAttribute(attr); + } + } + + this.mAttributeObserver = new MutationObserver( + this.handleMutation.bind(this) + ); + this.mAttributeObserver.observe(val, { attributeFilter }); + } else { + for (let attr of attributeFilter) { + this.removeAttribute(attr); + } + } + + var event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + + event = document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + this.dispatchEvent(event); + + return val; + } + + // nsIDOMXULSelectControlElement + get selectedItem() { + return this.mSelectedInternal; + } + + setInitialSelection() { + var popup = this.menupopup; + if (popup) { + var arr = popup.getElementsByAttribute("selected", "true"); + + var editable = this.editable; + var value = this.value; + if (!arr.item(0) && value) { + arr = popup.getElementsByAttribute( + editable ? "label" : "value", + value + ); + } + + if (arr.item(0)) { + this.selectedItem = arr[0]; + } else if (!editable) { + this.selectedIndex = 0; + } + } + } + + contains(item) { + if (!item) { + return false; + } + + var parent = item.parentNode; + return parent && parent.parentNode == this; + } + + handleMutation(aRecords) { + for (let record of aRecords) { + let t = record.target; + if (t == this.mSelectedInternal) { + let attrName = record.attributeName; + switch (attrName) { + case "value": + case "label": + case "image": + case "description": + if (t.hasAttribute(attrName)) { + this.setAttribute(attrName, t.getAttribute(attrName)); + } else { + this.removeAttribute(attrName); + } + } + } + } + } + + // nsIDOMXULSelectControlElement + getIndexOfItem(item) { + var popup = this.menupopup; + if (popup) { + var children = popup.children; + var i = children.length; + while (i--) { + if (children[i] == item) { + return i; + } + } + } + return -1; + } + + // nsIDOMXULSelectControlElement + getItemAtIndex(index) { + var popup = this.menupopup; + if (popup) { + var children = popup.children; + if (index >= 0 && index < children.length) { + return children[index]; + } + } + return null; + } + + appendItem(label, value, description) { + if (!this.menupopup) { + this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`)); + } + + var popup = this.menupopup; + popup.appendChild(MozXULElement.parseXULToFragment(`<menuitem />`)); + + var item = popup.lastElementChild; + if (label !== undefined) { + item.setAttribute("label", label); + } + item.setAttribute("value", value); + if (description) { + item.setAttribute("description", description); + } + + return item; + } + + removeAllItems() { + this.selectedItem = null; + var popup = this.menupopup; + if (popup) { + this.removeChild(popup); + } + } + + disconnectedCallback() { + if (this.mAttributeObserver) { + this.mAttributeObserver.disconnect(); + } + + if (this._labelBox) { + this._labelBox.remove(); + this._dropmarker.remove(); + this._labelBox = null; + this._dropmarker = null; + } + } + } + + MenuBaseControl.implementCustomInterface(MozMenuList, [ + Ci.nsIDOMXULMenuListElement, + Ci.nsIDOMXULSelectControlElement, + ]); + + customElements.define("menulist", MozMenuList); +} diff --git a/toolkit/content/widgets/menupopup.js b/toolkit/content/widgets/menupopup.js new file mode 100644 index 0000000000..a8513050bd --- /dev/null +++ b/toolkit/content/widgets/menupopup.js @@ -0,0 +1,255 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + class MozMenuPopup extends MozElements.MozElementMixin(XULPopupElement) { + constructor() { + super(); + + this.AUTOSCROLL_INTERVAL = 25; + this.NOT_DRAGGING = 0; + this.DRAG_OVER_BUTTON = -1; + this.DRAG_OVER_POPUP = 1; + this._draggingState = this.NOT_DRAGGING; + this._scrollTimer = 0; + + this.addEventListener("popupshowing", event => { + if (event.target != this) { + return; + } + + // Make sure we generated shadow DOM to place menuitems into. + this.shadowRoot; + }); + + this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + + this.hasConnected = true; + if (this.parentNode && this.parentNode.localName == "menulist") { + this._setUpMenulistPopup(); + } + } + + initShadowDOM() { + // Retarget events from shadow DOM arrowscrollbox to the host. + this.scrollBox.addEventListener("scroll", ev => + this.dispatchEvent(new Event("scroll")) + ); + this.scrollBox.addEventListener("overflow", ev => + this.dispatchEvent(new Event("overflow")) + ); + this.scrollBox.addEventListener("underflow", ev => + this.dispatchEvent(new Event("underflow")) + ); + } + + get shadowRoot() { + // We generate shadow DOM lazily on popupshowing event to avoid extra load + // on the system during browser startup. + if (!super.shadowRoot.firstElementChild) { + super.shadowRoot.appendChild(this.fragment); + this.initShadowDOM(); + } + return super.shadowRoot; + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment( + this.markup + ); + } + return document.importNode(this.constructor._fragment, true); + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css"/> + <html:style>${this.styles}</html:style> + <arrowscrollbox class="menupopup-arrowscrollbox" + exportparts="scrollbox: arrowscrollbox-scrollbox" + flex="1" + orient="vertical" + smoothscroll="false"> + <html:slot></html:slot> + </arrowscrollbox> + `; + } + + get styles() { + let s = ` + :host(.in-menulist) arrowscrollbox::part(scrollbutton-up), + :host(.in-menulist) arrowscrollbox::part(scrollbutton-down) { + display: none; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox) { + overflow: auto; + margin: 0; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox-clip) { + overflow: visible; + } + `; + + switch (AppConstants.platform) { + case "macosx": + s += ` + :host(.in-menulist) arrowscrollbox { + padding: 0; + } + `; + break; + + default: + break; + } + + return s; + } + + get scrollBox() { + if (!this._scrollBox) { + this._scrollBox = this.shadowRoot.querySelector("arrowscrollbox"); + } + return this._scrollBox; + } + + /** + * Adds event listeners for a MozMenuPopup inside a menulist element. + */ + _setUpMenulistPopup() { + // Access shadow root to generate menupoup shadow DOMs. We do generate + // shadow DOM on popupshowing, but it doesn't work for HTML:selects, + // which are implemented via menulist elements living in the main process. + // So make them a special case then. + this.shadowRoot; + this.classList.add("in-menulist"); + + this.addEventListener("popupshown", () => { + // Enable drag scrolling even when the mouse wasn't used. The + // mousemove handler will remove it if the mouse isn't down. + this._enableDragScrolling(false); + }); + + this.addEventListener("popuphidden", () => { + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + this.releaseCapture(); + this.scrollBox.scrollbox.scrollTop = 0; + }); + + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + if ( + this.state == "open" && + (event.target.localName == "menuitem" || + event.target.localName == "menu" || + event.target.localName == "menucaption") + ) { + this._enableDragScrolling(true); + } + }); + + this.addEventListener("mouseup", event => { + if (event.button != 0) { + return; + } + + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + }); + + this.addEventListener("mousemove", event => { + if (!this._draggingState) { + return; + } + + this._clearScrollTimer(); + + // If the user released the mouse before the menupopup opens, we will + // still be capturing, so check that the button is still pressed. If + // not, release the capture and do nothing else. This also handles if + // the dropdown was opened via the keyboard. + if (!(event.buttons & 1)) { + this._draggingState = this.NOT_DRAGGING; + this.releaseCapture(); + return; + } + + // If dragging outside the top or bottom edge of the menupopup, but + // within the menupopup area horizontally, scroll the list in that + // direction. The _draggingState flag is used to ensure that scrolling + // does not start until the mouse has moved over the menupopup first, + // preventing scrolling while over the dropdown button. + let popupRect = this.getOuterScreenRect(); + if ( + event.screenX >= popupRect.left && + event.screenX <= popupRect.right + ) { + if (this._draggingState == this.DRAG_OVER_BUTTON) { + if ( + event.screenY > popupRect.top && + event.screenY < popupRect.bottom + ) { + this._draggingState = this.DRAG_OVER_POPUP; + } + } + + if ( + this._draggingState == this.DRAG_OVER_POPUP && + (event.screenY <= popupRect.top || + event.screenY >= popupRect.bottom) + ) { + let scrollAmount = event.screenY <= popupRect.top ? -1 : 1; + this.scrollBox.scrollByIndex(scrollAmount, true); + + let win = this.ownerGlobal; + this._scrollTimer = win.setInterval(() => { + this.scrollBox.scrollByIndex(scrollAmount, true); + }, this.AUTOSCROLL_INTERVAL); + } + } + }); + + this._menulistPopupIsSetUp = true; + } + + _enableDragScrolling(overItem) { + if (!this._draggingState) { + this.setCaptureAlways(); + this._draggingState = overItem + ? this.DRAG_OVER_POPUP + : this.DRAG_OVER_BUTTON; + } + } + + _clearScrollTimer() { + if (this._scrollTimer) { + this.ownerGlobal.clearInterval(this._scrollTimer); + this._scrollTimer = 0; + } + } + } + + customElements.define("menupopup", MozMenuPopup); + + MozElements.MozMenuPopup = MozMenuPopup; +} diff --git a/toolkit/content/widgets/moz-input-box.js b/toolkit/content/widgets/moz-input-box.js new file mode 100644 index 0000000000..be1bc4d560 --- /dev/null +++ b/toolkit/content/widgets/moz-input-box.js @@ -0,0 +1,233 @@ +/* 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 is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const cachedFragments = { + get entities() { + return ["chrome://global/locale/textcontext.dtd"]; + }, + get editMenuItems() { + return ` + <menuitem data-l10n-id="text-action-undo" cmd="cmd_undo"></menuitem> + <menuseparator></menuseparator> + <menuitem data-l10n-id="text-action-cut" cmd="cmd_cut"></menuitem> + <menuitem data-l10n-id="text-action-copy" cmd="cmd_copy"></menuitem> + <menuitem data-l10n-id="text-action-paste" cmd="cmd_paste"></menuitem> + <menuitem data-l10n-id="text-action-delete" cmd="cmd_delete"></menuitem> + <menuseparator></menuseparator> + <menuitem data-l10n-id="text-action-select-all" cmd="cmd_selectAll"></menuitem> + `; + }, + get normal() { + delete this.normal; + this.normal = MozXULElement.parseXULToFragment( + ` + <menupopup class="textbox-contextmenu"> + ${this.editMenuItems} + </menupopup> + ` + ); + MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + return this.normal; + }, + get spellcheck() { + delete this.spellcheck; + this.spellcheck = MozXULElement.parseXULToFragment( + ` + <menupopup class="textbox-contextmenu"> + <menuitem label="&spellNoSuggestions.label;" anonid="spell-no-suggestions" disabled="true"></menuitem> + <menuitem label="&spellAddToDictionary.label;" accesskey="&spellAddToDictionary.accesskey;" anonid="spell-add-to-dictionary" oncommand="this.parentNode.parentNode.spellCheckerUI.addToDictionary();"></menuitem> + <menuitem label="&spellUndoAddToDictionary.label;" accesskey="&spellUndoAddToDictionary.accesskey;" anonid="spell-undo-add-to-dictionary" oncommand="this.parentNode.parentNode.spellCheckerUI.undoAddToDictionary();"></menuitem> + <menuseparator anonid="spell-suggestions-separator"></menuseparator> + ${this.editMenuItems} + <menuseparator anonid="spell-check-separator"></menuseparator> + <menuitem label="&spellCheckToggle.label;" type="checkbox" accesskey="&spellCheckToggle.accesskey;" anonid="spell-check-enabled" oncommand="this.parentNode.parentNode.spellCheckerUI.toggleEnabled();"></menuitem> + <menu label="&spellDictionaries.label;" accesskey="&spellDictionaries.accesskey;" anonid="spell-dictionaries"> + <menupopup anonid="spell-dictionaries-menu" onpopupshowing="event.stopPropagation();" onpopuphiding="event.stopPropagation();"></menupopup> + </menu> + </menupopup> + `, + this.entities + ); + return this.spellcheck; + }, + }; + + class MozInputBox extends MozXULElement { + static get observedAttributes() { + return ["spellcheck"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "spellcheck" && oldValue != newValue) { + this._initUI(); + } + } + + connectedCallback() { + this._initUI(); + } + + _initUI() { + this.spellcheck = this.hasAttribute("spellcheck"); + if (this.menupopup) { + this.menupopup.remove(); + } + + this.setAttribute("context", "_child"); + this.appendChild( + this.spellcheck + ? cachedFragments.spellcheck.cloneNode(true) + : cachedFragments.normal.cloneNode(true) + ); + this.menupopup = this.querySelector(".textbox-contextmenu"); + + this.menupopup.addEventListener("popupshowing", event => { + let input = this._input; + if (document.commandDispatcher.focusedElement != input) { + input.focus(); + } + this._doPopupItemEnabling(event.target); + }); + + if (this.spellcheck) { + this.menupopup.addEventListener("popuphiding", event => { + if (this.spellCheckerUI) { + this.spellCheckerUI.clearSuggestionsFromMenu(); + this.spellCheckerUI.clearDictionaryListFromMenu(); + } + }); + } + + this.menupopup.addEventListener("command", event => { + var cmd = event.originalTarget.getAttribute("cmd"); + if (cmd) { + this.doCommand(cmd); + event.stopPropagation(); + } + }); + } + + _doPopupItemEnablingSpell(popupNode) { + var spellui = this.spellCheckerUI; + if (!spellui || !spellui.canSpellCheck) { + this._setMenuItemVisibility("spell-no-suggestions", false); + this._setMenuItemVisibility("spell-check-enabled", false); + this._setMenuItemVisibility("spell-check-separator", false); + this._setMenuItemVisibility("spell-add-to-dictionary", false); + this._setMenuItemVisibility("spell-undo-add-to-dictionary", false); + this._setMenuItemVisibility("spell-suggestions-separator", false); + this._setMenuItemVisibility("spell-dictionaries", false); + return; + } + + spellui.initFromEvent( + document.popupRangeParent, + document.popupRangeOffset + ); + + var enabled = spellui.enabled; + var showUndo = spellui.canSpellCheck && spellui.canUndo(); + + var enabledCheckbox = this.getMenuItem("spell-check-enabled"); + enabledCheckbox.setAttribute("checked", enabled); + + var overMisspelling = spellui.overMisspelling; + this._setMenuItemVisibility("spell-add-to-dictionary", overMisspelling); + this._setMenuItemVisibility("spell-undo-add-to-dictionary", showUndo); + this._setMenuItemVisibility( + "spell-suggestions-separator", + overMisspelling || showUndo + ); + + // suggestion list + var suggestionsSeparator = this.getMenuItem("spell-no-suggestions"); + var numsug = spellui.addSuggestionsToMenu( + popupNode, + suggestionsSeparator, + 5 + ); + this._setMenuItemVisibility( + "spell-no-suggestions", + overMisspelling && numsug == 0 + ); + + // dictionary list + var dictionariesMenu = this.getMenuItem("spell-dictionaries-menu"); + var numdicts = spellui.addDictionaryListToMenu(dictionariesMenu, null); + this._setMenuItemVisibility( + "spell-dictionaries", + enabled && numdicts > 1 + ); + } + + _doPopupItemEnabling(popupNode) { + if (this.spellcheck) { + this._doPopupItemEnablingSpell(popupNode); + } + + var children = popupNode.childNodes; + for (var i = 0; i < children.length; i++) { + var command = children[i].getAttribute("cmd"); + if (command) { + var controller = document.commandDispatcher.getControllerForCommand( + command + ); + var enabled = controller.isCommandEnabled(command); + if (enabled) { + children[i].removeAttribute("disabled"); + } else { + children[i].setAttribute("disabled", "true"); + } + } + } + } + + get spellCheckerUI() { + if (!this._spellCheckInitialized) { + this._spellCheckInitialized = true; + + try { + ChromeUtils.import( + "resource://gre/modules/InlineSpellChecker.jsm", + this + ); + this.InlineSpellCheckerUI = new this.InlineSpellChecker( + this._input.editor + ); + } catch (ex) {} + } + + return this.InlineSpellCheckerUI; + } + + getMenuItem(anonid) { + return this.querySelector(`[anonid="${anonid}"]`); + } + + _setMenuItemVisibility(anonid, visible) { + this.getMenuItem(anonid).hidden = !visible; + } + + doCommand(command) { + var controller = document.commandDispatcher.getControllerForCommand( + command + ); + controller.doCommand(command); + } + + get _input() { + return ( + this.getElementsByAttribute("anonid", "input")[0] || + this.querySelector(".textbox-input") + ); + } + } + + customElements.define("moz-input-box", MozInputBox); +} diff --git a/toolkit/content/widgets/notificationbox.js b/toolkit/content/widgets/notificationbox.js new file mode 100644 index 0000000000..ca1d9ba5ca --- /dev/null +++ b/toolkit/content/widgets/notificationbox.js @@ -0,0 +1,474 @@ +/* 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 is loaded into chrome windows with the subscript loader. If you need to +// define globals, wrap in a block to prevent leaking onto `window`. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + MozElements.NotificationBox = class NotificationBox { + /** + * Creates a new class to handle a notification box, but does not add any + * elements to the DOM until a notification has to be displayed. + * + * @param insertElementFn + * Called with the "notification-stack" element as an argument when the + * first notification has to be displayed. + */ + constructor(insertElementFn) { + this._insertElementFn = insertElementFn; + this._animating = false; + this.currentNotification = null; + } + + get stack() { + if (!this._stack) { + let stack = document.createXULElement("legacy-stack"); + stack._notificationBox = this; + stack.className = "notificationbox-stack"; + stack.appendChild(document.createXULElement("spacer")); + stack.addEventListener("transitionend", event => { + if ( + event.target.localName == "notification" && + event.propertyName == "margin-top" + ) { + this._finishAnimation(); + } + }); + this._insertElementFn(stack); + this._stack = stack; + } + return this._stack; + } + + get _allowAnimation() { + return window.matchMedia("(prefers-reduced-motion: no-preference)") + .matches; + } + + get allNotifications() { + // Don't create any DOM if no new notification has been added yet. + if (!this._stack) { + return []; + } + + var closedNotification = this._closedNotification; + var notifications = this.stack.getElementsByTagName("notification"); + return Array.prototype.filter.call( + notifications, + n => n != closedNotification + ); + } + + getNotificationWithValue(aValue) { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + if (aValue == notifications[n].getAttribute("value")) { + return notifications[n]; + } + } + return null; + } + + /** + * Creates a <notification> element and shows it. The calling code can modify + * the element synchronously to add features to the notification. + * + * @param aLabel + * The main message text, or a DocumentFragment containing elements to + * add as children of the notification's main <description> element. + * @param aValue + * String identifier of the notification. + * @param aImage + * URL of the icon image to display. If not specified, a default icon + * based on the priority will be shown. + * @param aPriority + * One of the PRIORITY_ constants. These determine the appearance of + * the notification based on severity (using the "type" attribute), and + * only the notification with the highest priority is displayed. + * @param aButtons + * Array of objects defining action buttons: + * { + * label: + * Label of the <button> element. + * accessKey: + * Access key character for the <button> element. + * "l10n-id" + * Localization id for the <button>, to be used instead of + * specifying a separate label and access key. + * callback: + * When the button is used, this is called with the arguments: + * 1. The <notification> element. + * 2. This button object definition. + * 3. The <button> element. + * 4. The "command" event. + * popup: + * If specified, the button will open the popup element with this + * ID, anchored to the button. This is alternative to "callback". + * is: + * Defines a Custom Element name to use as the "is" value on + * button creation. + * } + * @param aEventCallback + * This may be called with the "removed", "dismissed" or "disconnected" + * parameter: + * removed - notification has been removed + * dismissed - user dismissed notification + * disconnected - notification removed in any way + * @param aNotificationIs + * Defines a Custom Element name to use as the "is" value on creation. + * This allows subclassing the created element. + * + * @return The <notification> element that is shown. + */ + appendNotification( + aLabel, + aValue, + aImage, + aPriority, + aButtons, + aEventCallback, + aNotificationIs + ) { + if ( + aPriority < this.PRIORITY_INFO_LOW || + aPriority > this.PRIORITY_CRITICAL_HIGH + ) { + throw new Error("Invalid notification priority " + aPriority); + } + + // check for where the notification should be inserted according to + // priority. If two are equal, the existing one appears on top. + var notifications = this.allNotifications; + var insertPos = null; + for (var n = notifications.length - 1; n >= 0; n--) { + if (notifications[n].priority < aPriority) { + break; + } + insertPos = notifications[n]; + } + + // Create the Custom Element and connect it to the document immediately. + var newitem = document.createXULElement( + "notification", + aNotificationIs ? { is: aNotificationIs } : {} + ); + this.stack.insertBefore(newitem, insertPos); + + // Custom notification classes may not have the messageText property. + if (newitem.messageText) { + // Can't use instanceof in case this was created from a different document: + if ( + aLabel && + typeof aLabel == "object" && + aLabel.nodeType && + aLabel.nodeType == aLabel.DOCUMENT_FRAGMENT_NODE + ) { + newitem.messageText.appendChild(aLabel); + } else { + newitem.messageText.textContent = aLabel; + } + } + newitem.setAttribute("value", aValue); + if (aImage) { + newitem.messageImage.setAttribute("src", aImage); + } + newitem.eventCallback = aEventCallback; + + if (aButtons) { + for (var b = 0; b < aButtons.length; b++) { + var button = aButtons[b]; + var buttonElem = document.createXULElement( + "button", + button.is ? { is: button.is } : {} + ); + + if (button["l10n-id"]) { + buttonElem.setAttribute("data-l10n-id", button["l10n-id"]); + } else { + buttonElem.setAttribute("label", button.label); + if (typeof button.accessKey == "string") { + buttonElem.setAttribute("accesskey", button.accessKey); + } + } + + buttonElem.classList.add("notification-button"); + if (button.primary) { + buttonElem.classList.add("primary"); + } + + newitem.messageDetails.appendChild(buttonElem); + buttonElem.buttonInfo = button; + } + } + + newitem.priority = aPriority; + if (aPriority >= this.PRIORITY_CRITICAL_LOW) { + newitem.setAttribute("type", "critical"); + } else if (aPriority <= this.PRIORITY_INFO_HIGH) { + newitem.setAttribute("type", "info"); + } else { + newitem.setAttribute("type", "warning"); + } + + if (!insertPos) { + newitem.style.display = "block"; + newitem.style.position = "fixed"; + newitem.style.top = "100%"; + newitem.style.marginTop = "-15px"; + newitem.style.opacity = "0"; + this._showNotification(newitem, true); + } + + // Fire event for accessibility APIs + var event = document.createEvent("Events"); + event.initEvent("AlertActive", true, true); + newitem.dispatchEvent(event); + + return newitem; + } + + removeNotification(aItem, aSkipAnimation) { + if (!aItem.parentNode) { + return; + } + if (aItem == this.currentNotification) { + this.removeCurrentNotification(aSkipAnimation); + } else if (aItem != this._closedNotification) { + this._removeNotificationElement(aItem); + } + } + + _removeNotificationElement(aChild) { + if (aChild.eventCallback) { + aChild.eventCallback("removed"); + } + this.stack.removeChild(aChild); + + // make sure focus doesn't get lost (workaround for bug 570835) + if (!Services.focus.getFocusedElementForWindow(window, false, {})) { + Services.focus.moveFocus( + window, + this.stack, + Services.focus.MOVEFOCUS_FORWARD, + 0 + ); + } + } + + removeCurrentNotification(aSkipAnimation) { + this._showNotification(this.currentNotification, false, aSkipAnimation); + } + + removeAllNotifications(aImmediate) { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + if (aImmediate) { + this._removeNotificationElement(notifications[n]); + } else { + this.removeNotification(notifications[n]); + } + } + this.currentNotification = null; + + // Clean up any currently-animating notification; this is necessary + // if a notification was just opened and is still animating, but we + // want to close it *without* animating. This can even happen if + // animations get disabled (via prefers-reduced-motion) and this method + // is called immediately after an animated notification was displayed + // (although this case isn't very likely). + if (aImmediate || !this._allowAnimation) { + this._finishAnimation(); + } + } + + removeTransientNotifications() { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + var notification = notifications[n]; + if (notification.persistence) { + notification.persistence--; + } else if (Date.now() > notification.timeout) { + this.removeNotification(notification, true); + } + } + } + + _showNotification(aNotification, aSlideIn, aSkipAnimation) { + this._finishAnimation(); + + var height = aNotification.getBoundingClientRect().height; + var skipAnimation = + aSkipAnimation || height == 0 || !this._allowAnimation; + aNotification.classList.toggle("animated", !skipAnimation); + + if (aSlideIn) { + this.currentNotification = aNotification; + aNotification.style.removeProperty("display"); + aNotification.style.removeProperty("position"); + aNotification.style.removeProperty("top"); + aNotification.style.removeProperty("margin-top"); + aNotification.style.removeProperty("opacity"); + + if (skipAnimation) { + return; + } + } else { + this._closedNotification = aNotification; + var notifications = this.allNotifications; + var idx = notifications.length - 1; + this.currentNotification = idx >= 0 ? notifications[idx] : null; + + if (skipAnimation) { + this._removeNotificationElement(this._closedNotification); + delete this._closedNotification; + return; + } + + aNotification.style.marginTop = -height + "px"; + aNotification.style.opacity = 0; + } + + this._animating = true; + } + + _finishAnimation() { + if (this._animating) { + this._animating = false; + if (this._closedNotification) { + this._removeNotificationElement(this._closedNotification); + delete this._closedNotification; + } + } + } + }; + + // These are defined on the instance prototype for backwards compatibility. + Object.assign(MozElements.NotificationBox.prototype, { + PRIORITY_INFO_LOW: 1, + PRIORITY_INFO_MEDIUM: 2, + PRIORITY_INFO_HIGH: 3, + PRIORITY_WARNING_LOW: 4, + PRIORITY_WARNING_MEDIUM: 5, + PRIORITY_WARNING_HIGH: 6, + PRIORITY_CRITICAL_LOW: 7, + PRIORITY_CRITICAL_MEDIUM: 8, + PRIORITY_CRITICAL_HIGH: 9, + }); + + MozElements.Notification = class Notification extends MozXULElement { + static get markup() { + return ` + <hbox class="messageDetails" align="center" flex="1" + oncommand="this.parentNode._doButtonCommand(event);"> + <image class="messageImage"/> + <description class="messageText" flex="1"/> + <spacer flex="1"/> + </hbox> + <toolbarbutton ondblclick="event.stopPropagation();" + class="messageCloseButton close-icon tabbable" + tooltiptext="&closeNotification.tooltip;" + oncommand="this.parentNode.dismiss();"/> + `; + } + + static get entities() { + return ["chrome://global/locale/notification.dtd"]; + } + + constructor() { + super(); + this.persistence = 0; + this.priority = 0; + this.timeout = 0; + } + + connectedCallback() { + this.appendChild(this.constructor.fragment); + + for (let [propertyName, selector] of [ + ["messageDetails", ".messageDetails"], + ["messageImage", ".messageImage"], + ["messageText", ".messageText"], + ["spacer", "spacer"], + ]) { + this[propertyName] = this.querySelector(selector); + } + } + + disconnectedCallback() { + if (this.eventCallback) { + this.eventCallback("disconnected"); + } + } + + get control() { + return this.closest(".notificationbox-stack")._notificationBox; + } + + /** + * Changes the text of an existing notification. If the notification was + * created with a custom fragment, it will be overwritten with plain text. + */ + set label(value) { + this.messageText.textContent = value; + } + + /** + * This method should only be called when the user has manually closed the + * notification. If you want to programmatically close the notification, you + * should call close() instead. + */ + dismiss() { + if (this.eventCallback) { + this.eventCallback("dismissed"); + } + this.close(); + } + + close() { + if (!this.parentNode) { + return; + } + this.control.removeNotification(this); + } + + _doButtonCommand(event) { + if (!("buttonInfo" in event.target)) { + return; + } + + var button = event.target.buttonInfo; + if (button.popup) { + document + .getElementById(button.popup) + .openPopup( + event.originalTarget, + "after_start", + 0, + 0, + false, + false, + event + ); + event.stopPropagation(); + } else { + var callback = button.callback; + if (callback) { + var result = callback(this, button, event.target, event); + if (!result) { + this.close(); + } + event.stopPropagation(); + } + } + } + }; + + customElements.define("notification", MozElements.Notification); +} diff --git a/toolkit/content/widgets/panel.js b/toolkit/content/widgets/panel.js new file mode 100644 index 0000000000..19c516e46d --- /dev/null +++ b/toolkit/content/widgets/panel.js @@ -0,0 +1,305 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + class MozPanel extends MozElements.MozElementMixin(XULPopupElement) { + static get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css"/> + <vbox class="panel-arrowcontainer" flex="1"> + <box class="panel-arrowbox" part="arrowbox"> + <image class="panel-arrow" part="arrow"/> + </box> + <box class="panel-arrowcontent" flex="1" part="arrowcontent"><html:slot/></box> + </vbox> + `; + } + constructor() { + super(); + + this.attachShadow({ mode: "open" }); + + this._prevFocus = 0; + this._fadeTimer = null; + + this.addEventListener("popupshowing", this); + this.addEventListener("popupshown", this); + this.addEventListener("popuphiding", this); + this.addEventListener("popuphidden", this); + this.addEventListener("popuppositioned", this); + } + + connectedCallback() { + // Create shadow DOM lazily if a panel is hidden. It helps to reduce + // cycles on startup. + if (!this.hidden) { + this.initialize(); + } + + if (this.isArrowPanel) { + if (!this.hasAttribute("flip")) { + this.setAttribute("flip", "both"); + } + if (!this.hasAttribute("side")) { + this.setAttribute("side", "top"); + } + if (!this.hasAttribute("position")) { + this.setAttribute("position", "bottomcenter topleft"); + } + if (!this.hasAttribute("consumeoutsideclicks")) { + this.setAttribute("consumeoutsideclicks", "false"); + } + } + } + + initialize() { + // As an optimization, we don't slot contents if the panel is [hidden] in + // connecetedCallack this means we can avoid running this code at startup + // and only need to do it when a panel is about to be shown. + // We then override the `hidden` setter and `removeAttribute` and call this + // function if the node is about to be shown. + if (this.shadowRoot.firstChild) { + return; + } + + if (!this.isArrowPanel) { + this.shadowRoot.appendChild(document.createElement("slot")); + } else { + this.shadowRoot.appendChild(this.constructor.fragment); + } + } + + get hidden() { + return super.hidden; + } + set hidden(v) { + if (!v) { + this.initialize(); + } + return (super.hidden = v); + } + + removeAttribute(name) { + if (name == "hidden") { + this.initialize(); + } + super.removeAttribute(name); + } + + get isArrowPanel() { + return this.getAttribute("type") == "arrow"; + } + + adjustArrowPosition(event) { + if (!this.isArrowPanel || !this.isAnchored) { + return; + } + + var container = this.shadowRoot.querySelector(".panel-arrowcontainer"); + var arrowbox = this.shadowRoot.querySelector(".panel-arrowbox"); + + var position = event.alignmentPosition; + var offset = event.alignmentOffset; + + this.setAttribute("arrowposition", position); + + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + container.setAttribute("orient", "horizontal"); + arrowbox.setAttribute("orient", "vertical"); + if (position.indexOf("_after") > 0) { + arrowbox.setAttribute("pack", "end"); + } else { + arrowbox.setAttribute("pack", "start"); + } + arrowbox.style.transform = "translate(0, " + -offset + "px)"; + + // The assigned side stays the same regardless of direction. + var isRTL = window.getComputedStyle(this).direction == "rtl"; + + if (position.indexOf("start_") == 0) { + container.style.MozBoxDirection = "reverse"; + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + container.style.removeProperty("-moz-box-direction"); + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if ( + position.indexOf("before_") == 0 || + position.indexOf("after_") == 0 + ) { + container.removeAttribute("orient"); + arrowbox.removeAttribute("orient"); + if (position.indexOf("_end") > 0) { + arrowbox.setAttribute("pack", "end"); + } else { + arrowbox.setAttribute("pack", "start"); + } + arrowbox.style.transform = "translate(" + -offset + "px, 0)"; + + if (position.indexOf("before_") == 0) { + container.style.MozBoxDirection = "reverse"; + this.setAttribute("side", "bottom"); + } else { + container.style.removeProperty("-moz-box-direction"); + this.setAttribute("side", "top"); + } + } + } + + on_popupshowing(event) { + if (this.isArrowPanel && event.target == this) { + var arrow = this.shadowRoot.querySelector(".panel-arrow"); + arrow.hidden = !this.isAnchored; + this.shadowRoot + .querySelector(".panel-arrowbox") + .style.removeProperty("transform"); + + if (this.getAttribute("animate") != "false") { + this.setAttribute("animate", "open"); + // the animating attribute prevents user interaction during transition + // it is removed when popupshown fires + this.setAttribute("animating", "true"); + } + + // set fading + var fade = this.getAttribute("fade"); + var fadeDelay = 0; + if (fade == "fast") { + fadeDelay = 1; + } else if (fade == "slow") { + fadeDelay = 4000; + } + + if (fadeDelay != 0) { + this._fadeTimer = setTimeout( + () => this.hidePopup(true), + fadeDelay, + this + ); + } + } + + // Capture the previous focus before has a chance to get set inside the panel + try { + this._prevFocus = Cu.getWeakReference( + document.commandDispatcher.focusedElement + ); + if (!this._prevFocus.get()) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + return; + } + } catch (ex) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + } + } + + on_popupshown(event) { + if (this.isArrowPanel && event.target == this) { + this.removeAttribute("animating"); + this.setAttribute("panelopen", "true"); + } + + // Fire event for accessibility APIs + let alertEvent = document.createEvent("Events"); + alertEvent.initEvent("AlertActive", true, true); + this.dispatchEvent(alertEvent); + } + + on_popuphiding(event) { + if (this.isArrowPanel && event.target == this) { + let animate = this.getAttribute("animate") != "false"; + + if (this._fadeTimer) { + clearTimeout(this._fadeTimer); + if (animate) { + this.setAttribute("animate", "fade"); + } + } else if (animate) { + this.setAttribute("animate", "cancel"); + } + } + + try { + this._currentFocus = document.commandDispatcher.focusedElement; + } catch (e) { + this._currentFocus = document.activeElement; + } + } + + on_popuphidden(event) { + if (this.isArrowPanel && event.target == this) { + this.removeAttribute("panelopen"); + if (this.getAttribute("animate") != "false") { + this.removeAttribute("animate"); + } + } + + function doFocus() { + // Focus was set on an element inside this panel, + // so we need to move it back to where it was previously. + // Elements can use refocused-by-panel to change their focus behaviour + // when re-focused by a panel hiding. + prevFocus.setAttribute("refocused-by-panel", true); + try { + let fm = Services.focus; + fm.setFocus(prevFocus, fm.FLAG_NOSCROLL); + } catch (e) { + prevFocus.focus(); + } + prevFocus.removeAttribute("refocused-by-panel"); + } + var currentFocus = this._currentFocus; + var prevFocus = this._prevFocus ? this._prevFocus.get() : null; + this._currentFocus = null; + this._prevFocus = null; + + // Avoid changing focus if focus changed while we hide the popup + // (This can happen e.g. if the popup is hiding as a result of a + // click/keypress that focused something) + let nowFocus; + try { + nowFocus = document.commandDispatcher.focusedElement; + } catch (e) { + nowFocus = document.activeElement; + } + if (nowFocus && nowFocus != currentFocus) { + return; + } + + if (prevFocus && this.getAttribute("norestorefocus") != "true") { + // Try to restore focus + try { + if (document.commandDispatcher.focusedWindow != window) { + // Focus has already been set to a window outside of this panel + return; + } + } catch (ex) {} + + if (!currentFocus) { + doFocus(); + return; + } + while (currentFocus) { + if (currentFocus == this) { + doFocus(); + return; + } + currentFocus = currentFocus.parentNode; + } + } + } + + on_popuppositioned(event) { + if (event.target == this) { + this.adjustArrowPosition(event); + } + } + } + + customElements.define("panel", MozPanel); +} diff --git a/toolkit/content/widgets/pluginProblem.js b/toolkit/content/widgets/pluginProblem.js new file mode 100644 index 0000000000..d918acd25a --- /dev/null +++ b/toolkit/content/widgets/pluginProblem.js @@ -0,0 +1,49 @@ +/* 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 is a UA Widget. It runs in per-origin UA widget scope, +// to be loaded by UAWidgetsChild.jsm. +// This widget results in a hidden element that occupies room where the plugin +// would be if we still supported plugins. +this.PluginProblemWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + // ownerGlobal is chrome-only, not accessible to UA Widget script here. + this.window = this.element.ownerDocument.defaultView; // eslint-disable-line mozilla/use-ownerGlobal + } + + onsetup() { + const parser = new this.window.DOMParser(); + parser.forceEnableDTD(); + let parserDoc = parser.parseFromString( + ` + <!DOCTYPE bindings [ + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > + %globalDTD; + %brandDTD; + ]> + <div xmlns="http://www.w3.org/1999/xhtml" class="mainBox" id="main" chromedir="&locale.dir;"> + <link rel="stylesheet" type="text/css" href="chrome://pluginproblem/content/pluginProblemContent.css" /> + </div> + `, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + + // Notify browser-plugins.js that we were attached, on a delay because + // this binding doesn't complete layout until the constructor + // completes. + this.element.dispatchEvent( + new this.window.CustomEvent("PluginBindingAttached") + ); + } +}; diff --git a/toolkit/content/widgets/popupnotification.js b/toolkit/content/widgets/popupnotification.js new file mode 100644 index 0000000000..1ec4c6f5a5 --- /dev/null +++ b/toolkit/content/widgets/popupnotification.js @@ -0,0 +1,159 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + class MozPopupNotification extends MozXULElement { + static get inheritedAttributes() { + return { + ".popup-notification-icon": "popupid,src=icon,class=iconclass", + ".popup-notification-origin": "value=origin,tooltiptext=origin", + ".popup-notification-description": "popupid", + ".popup-notification-description > span:first-of-type": + "text=label,popupid", + ".popup-notification-description > b:first-of-type": + "text=name,popupid", + ".popup-notification-description > span:nth-of-type(2)": + "text=endlabel,popupid", + ".popup-notification-description > b:last-of-type": + "text=secondname,popupid", + ".popup-notification-description > span:last-of-type": + "text=secondendlabel,popupid", + ".popup-notification-hint-text": "text=hinttext", + ".popup-notification-closebutton": + "oncommand=closebuttoncommand,hidden=closebuttonhidden", + ".popup-notification-learnmore-link": + "onclick=learnmoreclick,href=learnmoreurl", + ".popup-notification-warning": "hidden=warninghidden,text=warninglabel", + ".popup-notification-button-container > .popup-notification-secondary-button": + "oncommand=secondarybuttoncommand,label=secondarybuttonlabel,accesskey=secondarybuttonaccesskey,hidden=secondarybuttonhidden", + ".popup-notification-button-container > toolbarseparator": + "hidden=dropmarkerhidden", + ".popup-notification-dropmarker": + "onpopupshown=dropmarkerpopupshown,hidden=dropmarkerhidden", + ".popup-notification-dropmarker > menupopup": "oncommand=menucommand", + ".popup-notification-primary-button": + "oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey,default=buttonhighlight,disabled=mainactiondisabled", + }; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this._hasSlotted) { + return; + } + + super.attributeChangedCallback(name, oldValue, newValue); + } + + show() { + this.slotContents(); + + if (this.checkboxState) { + this.checkbox.checked = this.checkboxState.checked; + this.checkbox.setAttribute("label", this.checkboxState.label); + this.checkbox.hidden = false; + } else { + this.checkbox.hidden = true; + // Reset checked state to avoid wrong using of previous value. + this.checkbox.checked = false; + } + + this.hidden = false; + } + + static get markup() { + return ` + <hbox class="popup-notification-header-container"></hbox> + <hbox align="start" class="popup-notification-body-container"> + <image class="popup-notification-icon"/> + <vbox flex="1" pack="start" class="popup-notification-body"> + <hbox align="start"> + <vbox flex="1"> + <label class="popup-notification-origin header" crop="center"></label> + <!-- These need to be on the same line to avoid creating + whitespace between them (whitespace is added in the + localization file, if necessary). --> + <description class="popup-notification-description"><html:span></html:span><html:b></html:b><html:span></html:span><html:b></html:b><html:span></html:span></description> + <description class="popup-notification-hint-text"></description> + </vbox> + <toolbarbutton class="messageCloseButton close-icon popup-notification-closebutton tabbable" tooltiptext="&closeNotification.tooltip;"></toolbarbutton> + </hbox> + <label class="popup-notification-learnmore-link" is="text-link">&learnMoreNoEllipsis;</label> + <checkbox class="popup-notification-checkbox" oncommand="PopupNotifications._onCheckboxCommand(event)"></checkbox> + <description class="popup-notification-warning"></description> + </vbox> + </hbox> + <hbox class="popup-notification-footer-container"></hbox> + <hbox class="popup-notification-button-container panel-footer"> + <button class="popup-notification-button popup-notification-secondary-button"></button> + <toolbarseparator></toolbarseparator> + <button type="menu" class="popup-notification-button popup-notification-dropmarker" aria-label="&moreActionsButton.accessibleLabel;"> + <menupopup position="after_end" aria-label="&moreActionsButton.accessibleLabel;"> + </menupopup> + </button> + <button class="popup-notification-button popup-notification-primary-button" label="&defaultButton.label;" accesskey="&defaultButton.accesskey;"></button> + </hbox> + `; + } + + static get entities() { + return ["chrome://global/locale/notification.dtd"]; + } + + slotContents() { + if (this._hasSlotted) { + return; + } + this._hasSlotted = true; + this.appendChild(this.constructor.fragment); + + this.button = this.querySelector(".popup-notification-primary-button"); + this.secondaryButton = this.querySelector( + ".popup-notification-secondary-button" + ); + this.checkbox = this.querySelector(".popup-notification-checkbox"); + this.closebutton = this.querySelector(".popup-notification-closebutton"); + this.menubutton = this.querySelector(".popup-notification-dropmarker"); + this.menupopup = this.menubutton.querySelector("menupopup"); + + let popupnotificationfooter = this.querySelector( + "popupnotificationfooter" + ); + if (popupnotificationfooter) { + this.querySelector(".popup-notification-footer-container").append( + popupnotificationfooter + ); + } + + let popupnotificationheader = this.querySelector( + "popupnotificationheader" + ); + if (popupnotificationheader) { + this.querySelector(".popup-notification-header-container").append( + popupnotificationheader + ); + } + + for (let popupnotificationcontent of this.querySelectorAll( + "popupnotificationcontent" + )) { + this.appendNotificationContent(popupnotificationcontent); + } + + this.initializeAttributeInheritance(); + } + + appendNotificationContent(el) { + let nextSibling = this.querySelector( + ".popup-notification-body > .popup-notification-learnmore-link" + ); + nextSibling.before(el); + } + } + + customElements.define("popupnotification", MozPopupNotification); +} diff --git a/toolkit/content/widgets/radio.js b/toolkit/content/widgets/radio.js new file mode 100644 index 0000000000..6066241ed7 --- /dev/null +++ b/toolkit/content/widgets/radio.js @@ -0,0 +1,573 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +(() => { + class MozRadiogroup extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("mousedown", event => { + if (this.disabled) { + event.preventDefault(); + } + }); + + /** + * keyboard navigation Here's how keyboard navigation works in radio groups on Windows: + * The group takes 'focus' + * The user is then free to navigate around inside the group + * using the arrow keys. Accessing previous or following radio buttons + * is done solely through the arrow keys and not the tab button. Tab + * takes you to the next widget in the tab order + */ + this.addEventListener("keypress", event => { + if (event.key != " " || event.originalTarget != this) { + return; + } + this.selectedItem = this.focusedItem; + this.selectedItem.doCommand(); + // Prevent page from scrolling on the space key. + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_UP || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(false); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_LEFT || + event.originalTarget != this + ) { + return; + } + // left arrow goes back when we are ltr, forward when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "rtl" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_DOWN || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(true); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_RIGHT || + event.originalTarget != this + ) { + return; + } + // right arrow goes forward when we are ltr, back when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "ltr" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + /** + * set a focused attribute on the selected item when the group + * receives focus so that we can style it as if it were focused even though + * it is not (Windows platform behaviour is for the group to receive focus, + * not the item + */ + this.addEventListener("focus", event => { + if (event.originalTarget != this) { + return; + } + this.setAttribute("focused", "true"); + if (this.focusedItem) { + return; + } + + var val = this.selectedItem; + if (!val || val.disabled || val.hidden || val.collapsed) { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + val = children[i]; + break; + } + } + } + this.focusedItem = val; + }); + + this.addEventListener("blur", event => { + if (event.originalTarget != this) { + return; + } + this.removeAttribute("focused"); + this.focusedItem = null; + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + // When this is called via `connectedCallback` there are two main variations: + // 1) The radiogroup and radio children are defined in markup. + // 2) We are appending a DocumentFragment + // In both cases, the <radiogroup> connectedCallback fires first. But in (2), + // the children <radio>s won't be upgraded yet, so r.control will be undefined. + // To avoid churn in this case where we would have to reinitialize the list as each + // child radio gets upgraded as a result of init(), ignore the resulting calls + // to radioAttached. + this.ignoreRadioChildConstruction = true; + this.init(); + this.ignoreRadioChildConstruction = false; + if (!this.value) { + this.selectedIndex = 0; + } + } + + init() { + this._radioChildren = null; + + if (this.getAttribute("disabled") == "true") { + this.disabled = true; + } + + var children = this._getRadioChildren(); + var length = children.length; + for (var i = 0; i < length; i++) { + if (children[i].getAttribute("selected") == "true") { + this.selectedIndex = i; + return; + } + } + + var value = this.value; + if (value) { + this.value = value; + } + } + + /** + * Called when a new <radio> gets added to an already connected radiogroup. + * This can happen due to DOM getting appended after the <radiogroup> is created. + * When this happens, reinitialize the UI if necessary to make sure the state is + * consistent. + * + * @param {DOMNode} child + * The <radio> element that got added + */ + radioAttached(child) { + if (this.ignoreRadioChildConstruction) { + return; + } + if (!this._radioChildren || !this._radioChildren.includes(child)) { + this.init(); + } + } + + /** + * Called when a new <radio> gets removed from a radio group. + * + * @param {DOMNode} child + * The <radio> element that got removed + */ + radioUnattached(child) { + // Just invalidate the cache, next time it's fetched it'll get rebuilt. + this._radioChildren = null; + } + + set value(val) { + this.setAttribute("value", val); + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; i++) { + if (String(children[i].value) == String(val)) { + this.selectedItem = children[i]; + break; + } + } + return val; + } + + get value() { + return this.getAttribute("value"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + children[i].disabled = val; + } + return val; + } + + get disabled() { + if (this.getAttribute("disabled") == "true") { + return true; + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + return false; + } + } + return true; + } + + get itemCount() { + return this._getRadioChildren().length; + } + + set selectedIndex(val) { + this.selectedItem = this._getRadioChildren()[val]; + return val; + } + + get selectedIndex() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return i; + } + } + return -1; + } + + set selectedItem(val) { + var focused = this.getAttribute("focused") == "true"; + var alreadySelected = false; + + if (val) { + alreadySelected = val.getAttribute("selected") == "true"; + val.setAttribute("focused", focused); + val.setAttribute("selected", "true"); + this.setAttribute("value", val.value); + } else { + this.removeAttribute("value"); + } + + // uncheck all other group nodes + var children = this._getRadioChildren(); + var previousItem = null; + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + if (children[i].getAttribute("selected") == "true") { + previousItem = children[i]; + } + + children[i].removeAttribute("selected"); + children[i].removeAttribute("focused"); + } + } + + var event = document.createEvent("Events"); + event.initEvent("select", false, true); + this.dispatchEvent(event); + + if (focused) { + if (alreadySelected) { + // Notify accessibility that this item got focus. + event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } else { + // Only report if actual change + if (val) { + // Accessibility will fire focus for this. + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + val.dispatchEvent(event); + } + + if (previousItem) { + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + previousItem.dispatchEvent(event); + } + } + } + + return val; + } + + get selectedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return children[i]; + } + } + return null; + } + + set focusedItem(val) { + if (val) { + val.setAttribute("focused", "true"); + // Notify accessibility that this item got focus. + let event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } + + // unfocus all other group nodes + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + children[i].removeAttribute("focused"); + } + } + return val; + } + + get focusedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].getAttribute("focused") == "true") { + return children[i]; + } + } + return null; + } + + checkAdjacentElement(aNextFlag) { + var currentElement = this.focusedItem || this.selectedItem; + var i; + var children = this._getRadioChildren(); + for (i = 0; i < children.length; ++i) { + if (children[i] == currentElement) { + break; + } + } + var index = i; + + if (aNextFlag) { + do { + if (++i == children.length) { + i = 0; + } + if (i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } else { + do { + if (i == 0) { + i = children.length; + } + if (--i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } + } + + _getRadioChildren() { + if (this._radioChildren) { + return this._radioChildren; + } + + let radioChildren = []; + if (this.hasChildNodes()) { + for (let radio of this.querySelectorAll("radio")) { + customElements.upgrade(radio); + if (radio.control == this) { + radioChildren.push(radio); + } + } + } else { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + for (let radio of this.ownerDocument.getElementsByAttribute( + "group", + this.id + )) { + if (radio.namespaceURI == XUL_NS && radio.localName == "radio") { + customElements.upgrade(radio); + radioChildren.push(radio); + } + } + } + + return (this._radioChildren = radioChildren); + } + + getIndexOfItem(item) { + return this._getRadioChildren().indexOf(item); + } + + getItemAtIndex(index) { + var children = this._getRadioChildren(); + return index >= 0 && index < children.length ? children[index] : null; + } + + appendItem(label, value) { + var radio = document.createXULElement("radio"); + radio.setAttribute("label", label); + radio.setAttribute("value", value); + this.appendChild(radio); + return radio; + } + } + + MozXULElement.implementCustomInterface(MozRadiogroup, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULRadioGroupElement, + ]); + + customElements.define("radiogroup", MozRadiogroup); + + class MozRadio extends MozElements.BaseText { + static get markup() { + return ` + <image class="radio-check"></image> + <hbox class="radio-label-box" align="center" flex="1"> + <image class="radio-icon"></image> + <label class="radio-label" flex="1"></label> + </hbox> + `; + } + + static get inheritedAttributes() { + return { + ".radio-check": "disabled,selected", + ".radio-label": "text=label,accesskey,crop", + ".radio-icon": "src", + }; + } + + constructor() { + super(); + this.addEventListener("click", event => { + if (!this.disabled) { + this.control.selectedItem = this; + } + }); + + this.addEventListener("mousedown", event => { + if (!this.disabled) { + this.control.focusedItem = this; + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + if (!this.connectedOnce) { + this.connectedOnce = true; + // If the caller didn't provide custom content then append the default: + if (!this.firstElementChild) { + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + } + } + + var control = (this._control = this.control); + if (control) { + control.radioAttached(this); + } + } + + disconnectedCallback() { + if (this.control) { + this.control.radioUnattached(this); + } + this._control = null; + } + + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + get radioGroup() { + return this.control; + } + + get control() { + if (this._control) { + return this._control; + } + + var radiogroup = this.closest("radiogroup"); + if (radiogroup) { + return radiogroup; + } + + var group = this.getAttribute("group"); + if (!group) { + return null; + } + + var parent = this.ownerDocument.getElementById(group); + if (!parent || parent.localName != "radiogroup") { + parent = null; + } + return parent; + } + } + + MozXULElement.implementCustomInterface(MozRadio, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("radio", MozRadio); +})(); diff --git a/toolkit/content/widgets/richlistbox.js b/toolkit/content/widgets/richlistbox.js new file mode 100644 index 0000000000..e52c957a9b --- /dev/null +++ b/toolkit/content/widgets/richlistbox.js @@ -0,0 +1,1023 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + /** + * XUL:richlistbox element. + */ + MozElements.RichListBox = class RichListBox extends MozElements.BaseControl { + constructor() { + super(); + + this.selectedItems = new ChromeNodeList(); + this._currentIndex = null; + this._lastKeyTime = 0; + this._incrementalString = ""; + this._suppressOnSelect = false; + this._userSelecting = false; + this._selectTimeout = null; + this._currentItem = null; + this._selectionStart = null; + + this.addEventListener( + "keypress", + event => { + if (event.altKey || event.metaKey) { + return; + } + + switch (event.keyCode) { + case KeyEvent.DOM_VK_UP: + this._moveByOffsetFromUserEvent(-1, event); + break; + case KeyEvent.DOM_VK_DOWN: + this._moveByOffsetFromUserEvent(1, event); + break; + case KeyEvent.DOM_VK_HOME: + this._moveByOffsetFromUserEvent(-this.currentIndex, event); + break; + case KeyEvent.DOM_VK_END: + this._moveByOffsetFromUserEvent( + this.getRowCount() - this.currentIndex - 1, + event + ); + break; + case KeyEvent.DOM_VK_PAGE_UP: + this._moveByOffsetFromUserEvent(this.scrollOnePage(-1), event); + break; + case KeyEvent.DOM_VK_PAGE_DOWN: + this._moveByOffsetFromUserEvent(this.scrollOnePage(1), event); + break; + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener("keypress", event => { + if (event.target != this) { + return; + } + + if ( + event.key == " " && + event.ctrlKey && + !event.shiftKey && + !event.altKey && + !event.metaKey && + this.currentItem && + this.selType == "multiple" + ) { + this.toggleItemSelection(this.currentItem); + } + + if (!event.charCode || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + if (event.timeStamp - this._lastKeyTime > 1000) { + this._incrementalString = ""; + } + + var key = String.fromCharCode(event.charCode).toLowerCase(); + this._incrementalString += key; + this._lastKeyTime = event.timeStamp; + + // If all letters in the incremental string are the same, just + // try to match the first one + var incrementalString = /^(.)\1+$/.test(this._incrementalString) + ? RegExp.$1 + : this._incrementalString; + var length = incrementalString.length; + + var rowCount = this.getRowCount(); + var l = this.selectedItems.length; + var start = l > 0 ? this.getIndexOfItem(this.selectedItems[l - 1]) : -1; + // start from the first element if none was selected or from the one + // following the selected one if it's a new or a repeated-letter search + if (start == -1 || length == 1) { + start++; + } + + for (var i = 0; i < rowCount; i++) { + var k = (start + i) % rowCount; + var listitem = this.getItemAtIndex(k); + if (!this._canUserSelect(listitem)) { + continue; + } + // allow richlistitems to specify the string being searched for + var searchText = + "searchLabel" in listitem + ? listitem.searchLabel + : listitem.getAttribute("label"); // (see also bug 250123) + searchText = searchText.substring(0, length).toLowerCase(); + if (searchText == incrementalString) { + this.ensureIndexIsVisible(k); + this.timedSelect(listitem, this._selectDelay); + break; + } + } + }); + + this.addEventListener("focus", event => { + if (this.getRowCount() > 0) { + if (this.currentIndex == -1) { + this.currentIndex = this.getIndexOfFirstVisibleRow(); + let currentItem = this.getItemAtIndex(this.currentIndex); + if (currentItem) { + this.selectItem(currentItem); + } + } else { + this._fireEvent(this.currentItem, "DOMMenuItemActive"); + } + } + this._lastKeyTime = 0; + }); + + this.addEventListener("click", event => { + // clicking into nothing should unselect multiple selections + if (event.originalTarget == this && this.selType == "multiple") { + this.clearSelection(); + this.currentItem = null; + } + }); + + this.addEventListener("MozSwipeGesture", event => { + // Only handle swipe gestures up and down + switch (event.direction) { + case event.DIRECTION_DOWN: + this.scrollTop = this.scrollHeight; + break; + case event.DIRECTION_UP: + this.scrollTop = 0; + break; + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("allowevents", "true"); + this._refreshSelection(); + } + + // nsIDOMXULSelectControlElement + set selectedItem(val) { + this.selectItem(val); + } + get selectedItem() { + return this.selectedItems.length ? this.selectedItems[0] : null; + } + + // nsIDOMXULSelectControlElement + set selectedIndex(val) { + if (val >= 0) { + // This is a micro-optimization so that a call to getIndexOfItem or + // getItemAtIndex caused by _fireOnSelect (especially for derived + // widgets) won't loop the children. + this._selecting = { + item: this.getItemAtIndex(val), + index: val, + }; + this.selectItem(this._selecting.item); + delete this._selecting; + } else { + this.clearSelection(); + this.currentItem = null; + } + } + get selectedIndex() { + if (this.selectedItems.length) { + return this.getIndexOfItem(this.selectedItems[0]); + } + return -1; + } + + // nsIDOMXULSelectControlElement + set value(val) { + var kids = this.getElementsByAttribute("value", val); + if (kids && kids.item(0)) { + this.selectItem(kids[0]); + } + return val; + } + get value() { + if (this.selectedItems.length) { + return this.selectedItem.value; + } + return null; + } + + // nsIDOMXULSelectControlElement + get itemCount() { + return this.itemChildren.length; + } + + // nsIDOMXULSelectControlElement + set selType(val) { + this.setAttribute("seltype", val); + return val; + } + get selType() { + return this.getAttribute("seltype"); + } + + // nsIDOMXULSelectControlElement + set currentItem(val) { + if (this._currentItem == val) { + return val; + } + + if (this._currentItem) { + this._currentItem.current = false; + if (!val && !this.suppressMenuItemEvent) { + // An item is losing focus and there is no new item to focus. + // Notify a11y that there is no focused item. + this._fireEvent(this._currentItem, "DOMMenuItemInactive"); + } + } + this._currentItem = val; + + if (val) { + val.current = true; + if (!this.suppressMenuItemEvent) { + // Notify a11y that this item got focus. + this._fireEvent(val, "DOMMenuItemActive"); + } + } + + return val; + } + get currentItem() { + return this._currentItem; + } + + // nsIDOMXULSelectControlElement + set currentIndex(val) { + if (val >= 0) { + this.currentItem = this.getItemAtIndex(val); + } else { + this.currentItem = null; + } + } + get currentIndex() { + return this.currentItem ? this.getIndexOfItem(this.currentItem) : -1; + } + + // nsIDOMXULSelectControlElement + get selectedCount() { + return this.selectedItems.length; + } + + get itemChildren() { + let children = Array.from(this.children).filter( + node => node.localName == "richlistitem" + ); + return children; + } + + set suppressOnSelect(val) { + this.setAttribute("suppressonselect", val); + } + get suppressOnSelect() { + return this.getAttribute("suppressonselect") == "true"; + } + + set _selectDelay(val) { + this.setAttribute("_selectDelay", val); + } + get _selectDelay() { + return this.getAttribute("_selectDelay") || 50; + } + + _fireOnSelect() { + // make sure not to modify last-selected when suppressing select events + // (otherwise we'll lose the selection when a template gets rebuilt) + if (this._suppressOnSelect || this.suppressOnSelect) { + return; + } + + // remember the current item and all selected items with IDs + var state = this.currentItem ? this.currentItem.id : ""; + if (this.selType == "multiple" && this.selectedCount) { + let getId = function getId(aItem) { + return aItem.id; + }; + state += + " " + + [...this.selectedItems] + .filter(getId) + .map(getId) + .join(" "); + } + if (state) { + this.setAttribute("last-selected", state); + } else { + this.removeAttribute("last-selected"); + } + + // preserve the index just in case no IDs are available + if (this.currentIndex > -1) { + this._currentIndex = this.currentIndex + 1; + } + + var event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + + // always call this (allows a commandupdater without controller) + document.commandDispatcher.updateCommands("richlistbox-select"); + } + + getNextItem(aStartItem, aDelta) { + while (aStartItem) { + aStartItem = aStartItem.nextSibling; + if ( + aStartItem && + aStartItem.localName == "richlistitem" && + (!this._userSelecting || this._canUserSelect(aStartItem)) + ) { + --aDelta; + if (aDelta == 0) { + return aStartItem; + } + } + } + return null; + } + + getPreviousItem(aStartItem, aDelta) { + while (aStartItem) { + aStartItem = aStartItem.previousSibling; + if ( + aStartItem && + aStartItem.localName == "richlistitem" && + (!this._userSelecting || this._canUserSelect(aStartItem)) + ) { + --aDelta; + if (aDelta == 0) { + return aStartItem; + } + } + } + return null; + } + + appendItem(aLabel, aValue) { + var item = this.ownerDocument.createXULElement("richlistitem"); + item.setAttribute("value", aValue); + + var label = this.ownerDocument.createXULElement("label"); + label.setAttribute("value", aLabel); + label.setAttribute("flex", "1"); + label.setAttribute("crop", "end"); + item.appendChild(label); + + this.appendChild(item); + + return item; + } + + // nsIDOMXULSelectControlElement + getIndexOfItem(aItem) { + // don't search the children, if we're looking for none of them + if (aItem == null) { + return -1; + } + if (this._selecting && this._selecting.item == aItem) { + return this._selecting.index; + } + return this.itemChildren.indexOf(aItem); + } + + // nsIDOMXULSelectControlElement + getItemAtIndex(aIndex) { + if (this._selecting && this._selecting.index == aIndex) { + return this._selecting.item; + } + return this.itemChildren[aIndex] || null; + } + + // nsIDOMXULMultiSelectControlElement + addItemToSelection(aItem) { + if (this.selType != "multiple" && this.selectedCount) { + return; + } + + if (aItem.selected) { + return; + } + + this.selectedItems.append(aItem); + aItem.selected = true; + + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + removeItemFromSelection(aItem) { + if (!aItem.selected) { + return; + } + + this.selectedItems.remove(aItem); + aItem.selected = false; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + toggleItemSelection(aItem) { + if (aItem.selected) { + this.removeItemFromSelection(aItem); + } else { + this.addItemToSelection(aItem); + } + } + + // nsIDOMXULMultiSelectControlElement + selectItem(aItem) { + if (!aItem || aItem.disabled) { + return; + } + + if (this.selectedItems.length == 1 && this.selectedItems[0] == aItem) { + return; + } + + this._selectionStart = null; + + var suppress = this._suppressOnSelect; + this._suppressOnSelect = true; + + this.clearSelection(); + this.addItemToSelection(aItem); + this.currentItem = aItem; + + this._suppressOnSelect = suppress; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + selectItemRange(aStartItem, aEndItem) { + if (this.selType != "multiple") { + return; + } + + if (!aStartItem) { + aStartItem = this._selectionStart + ? this._selectionStart + : this.currentItem; + } + + if (!aStartItem) { + aStartItem = aEndItem; + } + + var suppressSelect = this._suppressOnSelect; + this._suppressOnSelect = true; + + this._selectionStart = aStartItem; + + var currentItem; + var startIndex = this.getIndexOfItem(aStartItem); + var endIndex = this.getIndexOfItem(aEndItem); + if (endIndex < startIndex) { + currentItem = aEndItem; + aEndItem = aStartItem; + aStartItem = currentItem; + } else { + currentItem = aStartItem; + } + + while (currentItem) { + this.addItemToSelection(currentItem); + if (currentItem == aEndItem) { + currentItem = this.getNextItem(currentItem, 1); + break; + } + currentItem = this.getNextItem(currentItem, 1); + } + + // Clear around new selection + // Don't use clearSelection() because it causes a lot of noise + // with respect to selection removed notifications used by the + // accessibility API support. + var userSelecting = this._userSelecting; + this._userSelecting = false; // that's US automatically unselecting + for (; currentItem; currentItem = this.getNextItem(currentItem, 1)) { + this.removeItemFromSelection(currentItem); + } + + for ( + currentItem = this.getItemAtIndex(0); + currentItem != aStartItem; + currentItem = this.getNextItem(currentItem, 1) + ) { + this.removeItemFromSelection(currentItem); + } + this._userSelecting = userSelecting; + + this._suppressOnSelect = suppressSelect; + + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + selectAll() { + this._selectionStart = null; + + var suppress = this._suppressOnSelect; + this._suppressOnSelect = true; + + var item = this.getItemAtIndex(0); + while (item) { + this.addItemToSelection(item); + item = this.getNextItem(item, 1); + } + + this._suppressOnSelect = suppress; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + clearSelection() { + if (this.selectedItems) { + while (this.selectedItems.length) { + let item = this.selectedItems[0]; + item.selected = false; + this.selectedItems.remove(item); + } + } + + this._selectionStart = null; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + getSelectedItem(aIndex) { + return aIndex < this.selectedItems.length + ? this.selectedItems[aIndex] + : null; + } + + ensureIndexIsVisible(aIndex) { + return this.ensureElementIsVisible(this.getItemAtIndex(aIndex)); + } + + ensureElementIsVisible(aElement, aAlignToTop) { + if (!aElement) { + return; + } + + // These calculations assume that there is no padding on the + // "richlistbox" element, although there might be a margin. + var targetRect = aElement.getBoundingClientRect(); + var scrollRect = this.getBoundingClientRect(); + var offset = targetRect.top - scrollRect.top; + if (!aAlignToTop && offset >= 0) { + // scrollRect.bottom wouldn't take a horizontal scroll bar into account + let scrollRectBottom = scrollRect.top + this.clientHeight; + offset = targetRect.bottom - scrollRectBottom; + if (offset <= 0) { + return; + } + } + this.scrollTop += offset; + } + + getIndexOfFirstVisibleRow() { + var children = this.itemChildren; + + for (var ix = 0; ix < children.length; ix++) { + if (this._isItemVisible(children[ix])) { + return ix; + } + } + + return -1; + } + + getRowCount() { + return this.itemChildren.length; + } + + scrollOnePage(aDirection) { + var children = this.itemChildren; + + if (!children.length) { + return 0; + } + + // If nothing is selected, we just select the first element + // at the extreme we're moving away from + if (!this.currentItem) { + return aDirection == -1 ? children.length : 0; + } + + // If the current item is visible, scroll by one page so that + // the new current item is at approximately the same position as + // the existing current item. + let height = this.getBoundingClientRect().height; + if (this._isItemVisible(this.currentItem)) { + this.scrollBy(0, height * aDirection); + } + + // Figure out, how many items fully fit into the view port + // (including the currently selected one), and determine + // the index of the first one lying (partially) outside + let currentItemRect = this.currentItem.getBoundingClientRect(); + var startBorder = currentItemRect.y; + if (aDirection == -1) { + startBorder += currentItemRect.height; + } + + var index = this.currentIndex; + for (var ix = index; 0 <= ix && ix < children.length; ix += aDirection) { + let childRect = children[ix].getBoundingClientRect(); + if (childRect.height == 0) { + continue; // hidden children have a y of 0 + } + var endBorder = childRect.y + (aDirection == -1 ? childRect.height : 0); + if ((endBorder - startBorder) * aDirection > height) { + break; // we've reached the desired distance + } + index = ix; + } + + return index != this.currentIndex + ? index - this.currentIndex + : aDirection; + } + + _refreshSelection() { + // when this method is called, we know that either the currentItem + // and selectedItems we have are null (ctor) or a reference to an + // element no longer in the DOM (template). + + // first look for the last-selected attribute + var state = this.getAttribute("last-selected"); + if (state) { + var ids = state.split(" "); + + var suppressSelect = this._suppressOnSelect; + this._suppressOnSelect = true; + this.clearSelection(); + for (let i = 1; i < ids.length; i++) { + var selectedItem = document.getElementById(ids[i]); + if (selectedItem) { + this.addItemToSelection(selectedItem); + } + } + + var currentItem = document.getElementById(ids[0]); + if (!currentItem && this._currentIndex) { + currentItem = this.getItemAtIndex( + Math.min(this._currentIndex - 1, this.getRowCount()) + ); + } + if (currentItem) { + this.currentItem = currentItem; + if (this.selType != "multiple" && this.selectedCount == 0) { + this.selectedItem = currentItem; + } + + if (this.getBoundingClientRect().height) { + this.ensureElementIsVisible(currentItem); + } else { + // XXX hack around a bug in ensureElementIsVisible as it will + // scroll beyond the last element, bug 493645. + this.ensureElementIsVisible(currentItem.previousElementSibling); + } + } + this._suppressOnSelect = suppressSelect; + // XXX actually it's just a refresh, but at least + // the Extensions manager expects this: + this._fireOnSelect(); + return; + } + + // try to restore the selected items according to their IDs + // (applies after a template rebuild, if last-selected was not set) + if (this.selectedItems) { + let itemIds = []; + for (let i = this.selectedCount - 1; i >= 0; i--) { + let selectedItem = this.selectedItems[i]; + itemIds.push(selectedItem.id); + this.selectedItems.remove(selectedItem); + } + for (let i = 0; i < itemIds.length; i++) { + let selectedItem = document.getElementById(itemIds[i]); + if (selectedItem) { + this.selectedItems.append(selectedItem); + } + } + } + if (this.currentItem && this.currentItem.id) { + this.currentItem = document.getElementById(this.currentItem.id); + } else { + this.currentItem = null; + } + + // if we have no previously current item or if the above check fails to + // find the previous nodes (which causes it to clear selection) + if (!this.currentItem && this.selectedCount == 0) { + this.currentIndex = this._currentIndex ? this._currentIndex - 1 : 0; + + // cf. listbox constructor: + // select items according to their attributes + var children = this.itemChildren; + for (let i = 0; i < children.length; ++i) { + if (children[i].getAttribute("selected") == "true") { + this.selectedItems.append(children[i]); + } + } + } + + if (this.selType != "multiple" && this.selectedCount == 0) { + this.selectedItem = this.currentItem; + } + } + + _isItemVisible(aItem) { + if (!aItem) { + return false; + } + + var y = this.getBoundingClientRect().y; + + // Partially visible items are also considered visible + let itemRect = aItem.getBoundingClientRect(); + return ( + itemRect.y + itemRect.height > y && itemRect.y < y + this.clientHeight + ); + } + + moveByOffset(aOffset, aIsSelecting, aIsSelectingRange) { + if ((aIsSelectingRange || !aIsSelecting) && this.selType != "multiple") { + return; + } + + var newIndex = this.currentIndex + aOffset; + if (newIndex < 0) { + newIndex = 0; + } + + var numItems = this.getRowCount(); + if (newIndex > numItems - 1) { + newIndex = numItems - 1; + } + + var newItem = this.getItemAtIndex(newIndex); + // make sure that the item is actually visible/selectable + if (this._userSelecting && newItem && !this._canUserSelect(newItem)) { + newItem = + aOffset > 0 + ? this.getNextItem(newItem, 1) || this.getPreviousItem(newItem, 1) + : this.getPreviousItem(newItem, 1) || this.getNextItem(newItem, 1); + } + if (newItem) { + this.ensureIndexIsVisible(this.getIndexOfItem(newItem)); + if (aIsSelectingRange) { + this.selectItemRange(null, newItem); + } else if (aIsSelecting) { + this.selectItem(newItem); + } + + this.currentItem = newItem; + } + } + + _moveByOffsetFromUserEvent(aOffset, aEvent) { + if (!aEvent.defaultPrevented) { + this._userSelecting = true; + this.moveByOffset(aOffset, !aEvent.ctrlKey, aEvent.shiftKey); + this._userSelecting = false; + aEvent.preventDefault(); + } + } + + _canUserSelect(aItem) { + var style = document.defaultView.getComputedStyle(aItem); + return ( + style.display != "none" && + style.visibility == "visible" && + style.MozUserInput != "none" + ); + } + + _selectTimeoutHandler(aMe) { + aMe._fireOnSelect(); + aMe._selectTimeout = null; + } + + timedSelect(aItem, aTimeout) { + var suppress = this._suppressOnSelect; + if (aTimeout != -1) { + this._suppressOnSelect = true; + } + + this.selectItem(aItem); + + this._suppressOnSelect = suppress; + + if (aTimeout != -1) { + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this._selectTimeout = window.setTimeout( + this._selectTimeoutHandler, + aTimeout, + this + ); + } + } + + /** + * For backwards-compatibility and for convenience. + * Use ensureElementIsVisible instead + */ + ensureSelectedElementIsVisible() { + return this.ensureElementIsVisible(this.selectedItem); + } + + _fireEvent(aTarget, aName) { + let event = document.createEvent("Events"); + event.initEvent(aName, true, true); + aTarget.dispatchEvent(event); + } + }; + + MozXULElement.implementCustomInterface(MozElements.RichListBox, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULMultiSelectControlElement, + ]); + + customElements.define("richlistbox", MozElements.RichListBox); + + /** + * XUL:richlistitem element. + */ + MozElements.MozRichlistitem = class MozRichlistitem extends MozElements.BaseText { + constructor() { + super(); + + this.selectedByMouseOver = false; + + /** + * If there is no modifier key, we select on mousedown, not + * click, so that drags work correctly. + */ + this.addEventListener("mousedown", event => { + var control = this.control; + if (!control || control.disabled) { + return; + } + if ( + (!event.ctrlKey || + (AppConstants.platform == "macosx" && event.button == 2)) && + !event.shiftKey && + !event.metaKey + ) { + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + } + }); + + /** + * On a click (up+down on the same item), deselect everything + * except this item. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + var control = this.control; + if (!control || control.disabled) { + return; + } + control._userSelecting = true; + if (control.selType != "multiple") { + control.selectItem(this); + } else if (event.ctrlKey || event.metaKey) { + control.toggleItemSelection(this); + control.currentItem = this; + } else if (event.shiftKey) { + control.selectItemRange(null, this); + control.currentItem = this; + } else { + /* We want to deselect all the selected items except what was + clicked, UNLESS it was a right-click. We have to do this + in click rather than mousedown so that you can drag a + selected group of items */ + + // use selectItemRange instead of selectItem, because this + // doesn't de- and reselect this item if it is selected + control.selectItemRange(this, this); + } + control._userSelecting = false; + }); + } + + /** + * nsIDOMXULSelectControlItemElement + */ + get label() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return Array.from( + this.getElementsByTagNameNS(XUL_NS, "label"), + label => label.value + ).join(" "); + } + + set searchLabel(val) { + if (val !== null) { + this.setAttribute("searchlabel", val); + } + // fall back to the label property (default value) + else { + this.removeAttribute("searchlabel"); + } + return val; + } + + get searchLabel() { + return this.hasAttribute("searchlabel") + ? this.getAttribute("searchlabel") + : this.label; + } + /** + * nsIDOMXULSelectControlItemElement + */ + set value(val) { + this.setAttribute("value", val); + return val; + } + + get value() { + return this.getAttribute("value"); + } + + /** + * nsIDOMXULSelectControlItemElement + */ + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + + return val; + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + /** + * nsIDOMXULSelectControlItemElement + */ + get control() { + var parent = this.parentNode; + while (parent) { + if (parent.localName == "richlistbox") { + return parent; + } + parent = parent.parentNode; + } + return null; + } + + set current(val) { + if (val) { + this.setAttribute("current", "true"); + } else { + this.removeAttribute("current"); + } + return val; + } + + get current() { + return this.getAttribute("current") == "true"; + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("richlistitem", MozElements.MozRichlistitem); +} diff --git a/toolkit/content/widgets/search-textbox.js b/toolkit/content/widgets/search-textbox.js new file mode 100644 index 0000000000..6a9ad2f261 --- /dev/null +++ b/toolkit/content/widgets/search-textbox.js @@ -0,0 +1,264 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + class MozSearchTextbox extends MozXULElement { + constructor() { + super(); + + this.inputField = document.createElement("input"); + + const METHODS = [ + "focus", + "blur", + "select", + "setUserInput", + "setSelectionRange", + ]; + for (const method of METHODS) { + this[method] = (...args) => this.inputField[method](...args); + } + + const READ_WRITE_PROPERTIES = [ + "defaultValue", + "placeholder", + "readOnly", + "size", + "selectionStart", + "selectionEnd", + ]; + for (const property of READ_WRITE_PROPERTIES) { + Object.defineProperty(this, property, { + enumerable: true, + get() { + return this.inputField[property]; + }, + set(val) { + return (this.inputField[property] = val); + }, + }); + } + + this.attachShadow({ mode: "open" }); + this.addEventListener("input", this); + this.addEventListener("keypress", this); + this.addEventListener("mousedown", this); + } + + static get inheritedAttributes() { + return { + input: + "value,maxlength,disabled,size,readonly,placeholder,tabindex,accesskey,mozactionhint,spellcheck", + ".textbox-search-icon": "label=searchbuttonlabel,disabled", + ".textbox-search-clear": "disabled", + }; + } + + static get markup() { + // TODO: Bug 1534799 - Convert string to Fluent and use manual DOM construction + return ` + <image class="textbox-search-clear" label="&searchTextBox.clear.label;"/> + `; + } + + static get entities() { + return ["chrome://global/locale/textcontext.dtd"]; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.connected) { + return; + } + this.connected = true; + this.textContent = ""; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "chrome://global/skin/search-textbox.css"; + + const textboxSign = document.createXULElement("image"); + textboxSign.className = "textbox-search-sign"; + textboxSign.part = "search-sign"; + + const input = this.inputField; + input.setAttribute("mozactionhint", "search"); + input.addEventListener("focus", this); + input.addEventListener("blur", this); + + const searchBtn = (this._searchButtonIcon = document.createXULElement( + "image" + )); + searchBtn.className = "textbox-search-icon"; + searchBtn.addEventListener("click", e => this._iconClick(e)); + + let clearBtn = this.constructor.fragment; + clearBtn = this._searchClearIcon = clearBtn.querySelector( + ".textbox-search-clear" + ); + clearBtn.addEventListener("click", () => this._clearSearch()); + + const deck = (this._searchIcons = document.createXULElement("deck")); + deck.className = "textbox-search-icons"; + deck.append(searchBtn, clearBtn); + this.shadowRoot.append(stylesheet, textboxSign, input, deck); + + this._timer = null; + + // Ensure the button state is up to date: + // eslint-disable-next-line no-self-assign + this.searchButton = this.searchButton; + + this.initializeAttributeInheritance(); + } + + set timeout(val) { + this.setAttribute("timeout", val); + return val; + } + + get timeout() { + return parseInt(this.getAttribute("timeout")) || 500; + } + + set searchButton(val) { + if (val) { + this.setAttribute("searchbutton", "true"); + this.removeAttribute("aria-autocomplete"); + this._searchButtonIcon.setAttribute("role", "button"); + } else { + this.removeAttribute("searchbutton"); + this.setAttribute("aria-autocomplete", "list"); + this._searchButtonIcon.setAttribute("role", "none"); + } + return val; + } + + get searchButton() { + return this.getAttribute("searchbutton") == "true"; + } + + set value(val) { + this.inputField.value = val; + + if (val) { + this._searchIcons.selectedIndex = this.searchButton ? 0 : 1; + } else { + this._searchIcons.selectedIndex = 0; + } + + if (this._timer) { + clearTimeout(this._timer); + } + return val; + } + + get value() { + return this.inputField.value; + } + + get editor() { + return this.inputField.editor; + } + + set disabled(val) { + this.inputField.disabled = val; + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + return val; + } + + get disabled() { + return this.inputField.disabled; + } + + on_blur() { + this.removeAttribute("focused"); + } + + on_focus() { + this.setAttribute("focused", "true"); + } + + on_input() { + if (this.searchButton) { + this._searchIcons.selectedIndex = 0; + return; + } + if (this._timer) { + clearTimeout(this._timer); + } + this._timer = + this.timeout && setTimeout(this._fireCommand, this.timeout, this); + this._searchIcons.selectedIndex = this.value ? 1 : 0; + } + + on_keypress(event) { + switch (event.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + if (this._clearSearch()) { + event.preventDefault(); + event.stopPropagation(); + } + break; + case KeyEvent.DOM_VK_RETURN: + this._enterSearch(); + event.preventDefault(); + event.stopPropagation(); + break; + } + } + + on_mousedown(event) { + if (!this.hasAttribute("focused")) { + this.setSelectionRange(0, 0); + this.focus(); + } + } + + _fireCommand(me) { + if (me._timer) { + clearTimeout(me._timer); + } + me._timer = null; + me.doCommand(); + } + + _iconClick() { + if (this.searchButton) { + this._enterSearch(); + } else { + this.focus(); + } + } + + _enterSearch() { + if (this.disabled) { + return; + } + if (this.searchButton && this.value && !this.readOnly) { + this._searchIcons.selectedIndex = 1; + } + this._fireCommand(this); + } + + _clearSearch() { + if (!this.disabled && !this.readOnly && this.value) { + this.value = ""; + this._fireCommand(this); + this._searchIcons.selectedIndex = 0; + return true; + } + return false; + } + } + + customElements.define("search-textbox", MozSearchTextbox); +} diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js new file mode 100644 index 0000000000..56bdbfd8a7 --- /dev/null +++ b/toolkit/content/widgets/spinner.js @@ -0,0 +1,538 @@ +/* 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"; + +/* + * The spinner is responsible for displaying the items, and does + * not care what the values represent. The setValue function is called + * when it detects a change in value triggered by scroll event. + * Supports scrolling, clicking on up or down, clicking on item, and + * dragging. + */ + +function Spinner(props, context) { + this.context = context; + this._init(props); +} + +{ + const ITEM_HEIGHT = 2.5, + VIEWPORT_SIZE = 7, + VIEWPORT_COUNT = 5; + + Spinner.prototype = { + /** + * Initializes a spinner. Set the default states and properties, cache + * element references, create the HTML markup, and add event listeners. + * + * @param {Object} props [Properties passed in from parent] + * { + * {Function} setValue: Takes a value and set the state to + * the parent component. + * {Function} getDisplayString: Takes a value, and output it + * as localized strings. + * {Number} viewportSize [optional]: Number of items in a + * viewport. + * {Boolean} hideButtons [optional]: Hide up & down buttons + * {Number} rootFontSize [optional]: Used to support zoom in/out + * } + */ + _init(props) { + const { + id, + setValue, + getDisplayString, + hideButtons, + rootFontSize = 10, + } = props; + + const spinnerTemplate = document.getElementById("spinner-template"); + const spinnerElement = document.importNode(spinnerTemplate.content, true); + + // Make sure viewportSize is an odd number because we want to have the selected + // item in the center. If it's an even number, use the default size instead. + const viewportSize = + props.viewportSize % 2 ? props.viewportSize : VIEWPORT_SIZE; + + this.state = { + items: [], + isScrolling: false, + }; + this.props = { + setValue, + getDisplayString, + viewportSize, + rootFontSize, + // We can assume that the viewportSize is an odd number. Calculate how many + // items we need to insert on top of the spinner so that the selected is at + // the center. Ex: if viewportSize is 5, we need 2 items on top. + viewportTopOffset: (viewportSize - 1) / 2, + }; + this.elements = { + container: spinnerElement.querySelector(".spinner-container"), + spinner: spinnerElement.querySelector(".spinner"), + up: spinnerElement.querySelector(".up"), + down: spinnerElement.querySelector(".down"), + itemsViewElements: [], + }; + + this.elements.spinner.style.height = ITEM_HEIGHT * viewportSize + "rem"; + + if (id) { + this.elements.container.id = id; + } + if (hideButtons) { + this.elements.container.classList.add("hide-buttons"); + } + + this.context.appendChild(spinnerElement); + this._attachEventListeners(); + }, + + /** + * Only the parent component calls setState on the spinner. + * It checks if the items have changed and updates the spinner. + * If only the value has changed, smooth scrolls to the new value. + * + * @param {Object} newState [The new spinner state] + * { + * {Number/String} value: The centered value + * {Array} items: The list of items for display + * {Boolean} isInfiniteScroll: Whether or not the spinner should + * have infinite scroll capability + * {Boolean} isValueSet: true if user has selected a value + * } + */ + setState(newState) { + const { value, items } = this.state; + const { + value: newValue, + items: newItems, + isValueSet, + isInvalid, + smoothScroll = true, + } = newState; + + if (this._isArrayDiff(newItems, items)) { + this.state = Object.assign(this.state, newState); + this._updateItems(); + this._scrollTo(newValue, true); + } else if (newValue != value) { + this.state = Object.assign(this.state, newState); + if (smoothScroll) { + this._smoothScrollTo(newValue, true); + } else { + this._scrollTo(newValue, true); + } + } + + if (isValueSet && !isInvalid) { + this._updateSelection(); + } else { + this._removeSelection(); + } + }, + + /** + * Whenever scroll event is detected: + * - Update the index state + * - If the value has changed, update the [value] state and call [setValue] + * - If infinite scrolling is on, reset the scrolling position if necessary + */ + _onScroll() { + const { items, itemsView, isInfiniteScroll } = this.state; + const { viewportSize, viewportTopOffset } = this.props; + const { spinner } = this.elements; + + this.state.index = this._getIndexByOffset(spinner.scrollTop); + + const value = itemsView[this.state.index + viewportTopOffset].value; + + // Call setValue if value has changed + if (this.state.value != value) { + this.state.value = value; + this.props.setValue(value); + } + + // Do infinite scroll when items length is bigger or equal to viewport + // and isInfiniteScroll is not false. + if (items.length >= viewportSize && isInfiniteScroll) { + // If the scroll position is near the top or bottom, jump back to the middle + // so user can keep scrolling up or down. + if ( + this.state.index < viewportSize || + this.state.index > itemsView.length - viewportSize + ) { + this._scrollTo(this.state.value, true); + } + } + + this.elements.spinner.classList.add("scrolling"); + }, + + /** + * Remove the "scrolling" state on scrollend. + */ + _onScrollend() { + this.elements.spinner.classList.remove("scrolling"); + }, + + /** + * Updates the spinner items to the current states. + */ + _updateItems() { + const { viewportSize, viewportTopOffset } = this.props; + const { items, isInfiniteScroll } = this.state; + + // Prepends null elements so the selected value is centered in spinner + let itemsView = new Array(viewportTopOffset).fill({}).concat(items); + + if (items.length >= viewportSize && isInfiniteScroll) { + // To achieve infinite scroll, we move the scroll position back to the + // center when it is near the top or bottom. The scroll momentum could + // be lost in the process, so to minimize that, we need at least 2 sets + // of items to act as buffer: one for the top and one for the bottom. + // But if the number of items is small ( < viewportSize * viewport count) + // we should add more sets. + let count = + Math.ceil((viewportSize * VIEWPORT_COUNT) / items.length) * 2; + for (let i = 0; i < count; i += 1) { + itemsView.push(...items); + } + } + + // Reuse existing DOM nodes when possible. Create or remove + // nodes based on how big itemsView is. + this._prepareNodes(itemsView.length, this.elements.spinner); + // Once DOM nodes are ready, set display strings using textContent + this._setDisplayStringAndClass( + itemsView, + this.elements.itemsViewElements + ); + + this.state.itemsView = itemsView; + }, + + /** + * Make sure the number or child elements is the same as length + * and keep the elements' references for updating textContent + * + * @param {Number} length [The number of child elements] + * @param {DOMElement} parent [The parent element reference] + */ + _prepareNodes(length, parent) { + const diff = length - parent.childElementCount; + + if (!diff) { + return; + } + + if (diff > 0) { + // Add more elements if length is greater than current + let frag = document.createDocumentFragment(); + + // Remove margin bottom on the last element before appending + if (parent.lastChild) { + parent.lastChild.style.marginBottom = ""; + } + + for (let i = 0; i < diff; i++) { + let el = document.createElement("div"); + frag.appendChild(el); + this.elements.itemsViewElements.push(el); + } + parent.appendChild(frag); + } else if (diff < 0) { + // Remove elements if length is less than current + for (let i = 0; i < Math.abs(diff); i++) { + parent.removeChild(parent.lastChild); + } + this.elements.itemsViewElements.splice(diff); + } + + parent.lastChild.style.marginBottom = + ITEM_HEIGHT * this.props.viewportTopOffset + "rem"; + }, + + /** + * Set the display string and class name to the elements. + * + * @param {Array<Object>} items + * [{ + * {Number/String} value: The value in its original form + * {Boolean} enabled: Whether or not the item is enabled + * }] + * @param {Array<DOMElement>} elements + */ + _setDisplayStringAndClass(items, elements) { + const { getDisplayString } = this.props; + + items.forEach((item, index) => { + elements[index].textContent = + item.value != undefined ? getDisplayString(item.value) : ""; + elements[index].className = item.enabled ? "" : "disabled"; + }); + }, + + /** + * Attach event listeners to the spinner and buttons. + */ + _attachEventListeners() { + const { spinner, container } = this.elements; + + spinner.addEventListener("scroll", this, { passive: true }); + spinner.addEventListener("scrollend", this, { passive: true }); + container.addEventListener("mouseup", this, { passive: true }); + container.addEventListener("mousedown", this, { passive: true }); + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + const { mouseState = {}, index, itemsView } = this.state; + const { viewportTopOffset, setValue } = this.props; + const { spinner, up, down } = this.elements; + + switch (event.type) { + case "scroll": { + this._onScroll(); + break; + } + case "scrollend": { + this._onScrollend(); + break; + } + case "mousedown": { + this.state.mouseState = { + down: true, + layerX: event.layerX, + layerY: event.layerY, + }; + if (event.target == up) { + // An "active" class is needed to simulate :active pseudo-class + // because element is not focused. + event.target.classList.add("active"); + this._smoothScrollToIndex(index - 1); + } + if (event.target == down) { + event.target.classList.add("active"); + this._smoothScrollToIndex(index + 1); + } + if (event.target.parentNode == spinner) { + // Listen to dragging events + spinner.addEventListener("mousemove", this, { passive: true }); + spinner.addEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mouseup": { + this.state.mouseState.down = false; + if (event.target == up || event.target == down) { + event.target.classList.remove("active"); + } + if (event.target.parentNode == spinner) { + // Check if user clicks or drags, scroll to the item if clicked, + // otherwise get the current index and smooth scroll there. + if ( + event.layerX == mouseState.layerX && + event.layerY == mouseState.layerY + ) { + const newIndex = + this._getIndexByOffset(event.target.offsetTop) - + viewportTopOffset; + if (index == newIndex) { + // Set value manually if the clicked element is already centered. + // This happens when the picker first opens, and user pick the + // default value. + setValue(itemsView[index + viewportTopOffset].value); + } else { + this._smoothScrollToIndex(newIndex); + } + } else { + this._smoothScrollToIndex( + this._getIndexByOffset(spinner.scrollTop) + ); + } + // Stop listening to dragging + spinner.removeEventListener("mousemove", this, { passive: true }); + spinner.removeEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mouseleave": { + if (event.target == spinner) { + // Stop listening to drag event if mouse is out of the spinner + this._smoothScrollToIndex( + this._getIndexByOffset(spinner.scrollTop) + ); + spinner.removeEventListener("mousemove", this, { passive: true }); + spinner.removeEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mousemove": { + // Change spinner position on drag + spinner.scrollTop -= event.movementY; + break; + } + } + }, + + /** + * Find the index by offset + * @param {Number} offset: Offset value in pixel. + * @return {Number} Index number + */ + _getIndexByOffset(offset) { + return Math.round(offset / (ITEM_HEIGHT * this.props.rootFontSize)); + }, + + /** + * Find the index of a value that is the closest to the current position. + * If centering is true, find the index closest to the center. + * + * @param {Number/String} value: The value to find + * @param {Boolean} centering: Whether or not to find the value closest to center + * @return {Number} index of the value, returns -1 if value is not found + */ + _getScrollIndex(value, centering) { + const { itemsView } = this.state; + const { viewportTopOffset } = this.props; + + // If index doesn't exist, or centering is true, start from the middle point + let currentIndex = + centering || this.state.index == undefined + ? Math.round((itemsView.length - viewportTopOffset) / 2) + : this.state.index; + let closestIndex = itemsView.length; + let indexes = []; + let diff = closestIndex; + let isValueFound = false; + + // Find indexes of items match the value + itemsView.forEach((item, index) => { + if (item.value == value) { + indexes.push(index); + } + }); + + // Find the index closest to currentIndex + indexes.forEach(index => { + let d = Math.abs(index - currentIndex); + if (d < diff) { + diff = d; + closestIndex = index; + isValueFound = true; + } + }); + + return isValueFound ? closestIndex - viewportTopOffset : -1; + }, + + /** + * Scroll to a value. + * + * @param {Number/String} value: Value to scroll to + * @param {Boolean} centering: Whether or not to scroll to center location + */ + _scrollTo(value, centering) { + const index = this._getScrollIndex(value, centering); + // Do nothing if the value is not found + if (index > -1) { + this.state.index = index; + this.elements.spinner.scrollTop = + this.state.index * ITEM_HEIGHT * this.props.rootFontSize; + } + }, + + /** + * Smooth scroll to a value. + * + * @param {Number/String} value: Value to scroll to + */ + _smoothScrollTo(value) { + const index = this._getScrollIndex(value); + // Do nothing if the value is not found + if (index > -1) { + this.state.index = index; + this._smoothScrollToIndex(this.state.index); + } + }, + + /** + * Smooth scroll to a value based on the index + * + * @param {Number} index: Index number + */ + _smoothScrollToIndex(index) { + const element = this.elements.spinner.children[index]; + if (element) { + element.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, + + /** + * Update the selection state. + */ + _updateSelection() { + const { itemsViewElements, selected } = this.elements; + const { itemsView, index } = this.state; + const { viewportTopOffset } = this.props; + const currentItemIndex = index + viewportTopOffset; + + if (selected && selected != itemsViewElements[currentItemIndex]) { + this._removeSelection(); + } + + this.elements.selected = itemsViewElements[currentItemIndex]; + if (itemsView[currentItemIndex] && itemsView[currentItemIndex].enabled) { + this.elements.selected.classList.add("selection"); + } + }, + + /** + * Remove selection if selected exists and different from current + */ + _removeSelection() { + const { selected } = this.elements; + if (selected) { + selected.classList.remove("selection"); + } + }, + + /** + * Compares arrays of objects. It assumes the structure is an array of + * objects, and objects in a and b have the same number of properties. + * + * @param {Array<Object>} a + * @param {Array<Object>} b + * @return {Boolean} Returns true if a and b are different + */ + _isArrayDiff(a, b) { + // Check reference first, exit early if reference is the same. + if (a == b) { + return false; + } + + if (a.length != b.length) { + return true; + } + + for (let i = 0; i < a.length; i++) { + for (let prop in a[i]) { + if (a[i][prop] != b[i][prop]) { + return true; + } + } + } + return false; + }, + }; +} diff --git a/toolkit/content/widgets/stringbundle.js b/toolkit/content/widgets/stringbundle.js new file mode 100644 index 0000000000..c780950f16 --- /dev/null +++ b/toolkit/content/widgets/stringbundle.js @@ -0,0 +1,78 @@ +/* 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 is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + class MozStringbundle extends MozXULElement { + get stringBundle() { + if (!this._bundle) { + try { + this._bundle = Services.strings.createBundle(this.src); + } catch (e) { + dump("Failed to get stringbundle:\n"); + dump(e + "\n"); + } + } + return this._bundle; + } + + set src(val) { + this._bundle = null; + this.setAttribute("src", val); + return val; + } + + get src() { + return this.getAttribute("src"); + } + + get strings() { + // Note: this is a sucky method name! Should be: + // readonly attribute nsISimpleEnumerator strings; + return this.stringBundle.getSimpleEnumeration(); + } + + getString(aStringKey) { + try { + return this.stringBundle.GetStringFromName(aStringKey); + } catch (e) { + dump( + "*** Failed to get string " + + aStringKey + + " in bundle: " + + this.src + + "\n" + ); + throw e; + } + } + + getFormattedString(aStringKey, aStringsArray) { + try { + return this.stringBundle.formatStringFromName( + aStringKey, + aStringsArray + ); + } catch (e) { + dump( + "*** Failed to format string " + + aStringKey + + " in bundle: " + + this.src + + "\n" + ); + throw e; + } + } + } + + customElements.define("stringbundle", MozStringbundle); +} diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js new file mode 100644 index 0000000000..f09ac29995 --- /dev/null +++ b/toolkit/content/widgets/tabbox.js @@ -0,0 +1,831 @@ +/* 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 is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + let imports = {}; + ChromeUtils.defineModuleGetter( + imports, + "ShortcutUtils", + "resource://gre/modules/ShortcutUtils.jsm" + ); + + class MozTabbox extends MozXULElement { + constructor() { + super(); + this._handleMetaAltArrows = AppConstants.platform == "macosx"; + this.disconnectedCallback = this.disconnectedCallback.bind(this); + } + + connectedCallback() { + Services.els.addSystemEventListener(document, "keydown", this, false); + window.addEventListener("unload", this.disconnectedCallback, { + once: true, + }); + } + + disconnectedCallback() { + window.removeEventListener("unload", this.disconnectedCallback); + Services.els.removeSystemEventListener(document, "keydown", this, false); + } + + set handleCtrlTab(val) { + this.setAttribute("handleCtrlTab", val); + return val; + } + + get handleCtrlTab() { + return this.getAttribute("handleCtrlTab") != "false"; + } + + get tabs() { + if (this.hasAttribute("tabcontainer")) { + return document.getElementById(this.getAttribute("tabcontainer")); + } + return this.getElementsByTagNameNS( + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + "tabs" + ).item(0); + } + + get tabpanels() { + return this.getElementsByTagNameNS( + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + "tabpanels" + ).item(0); + } + + set selectedIndex(val) { + let tabs = this.tabs; + if (tabs) { + tabs.selectedIndex = val; + } + this.setAttribute("selectedIndex", val); + return val; + } + + get selectedIndex() { + let tabs = this.tabs; + return tabs ? tabs.selectedIndex : -1; + } + + set selectedTab(val) { + if (val) { + let tabs = this.tabs; + if (tabs) { + tabs.selectedItem = val; + } + } + return val; + } + + get selectedTab() { + let tabs = this.tabs; + return tabs && tabs.selectedItem; + } + + set selectedPanel(val) { + if (val) { + let tabpanels = this.tabpanels; + if (tabpanels) { + tabpanels.selectedPanel = val; + } + } + return val; + } + + get selectedPanel() { + let tabpanels = this.tabpanels; + return tabpanels && tabpanels.selectedPanel; + } + + handleEvent(event) { + if (!event.isTrusted) { + // Don't let untrusted events mess with tabs. + return; + } + + // Skip this only if something has explicitly cancelled it. + if (event.defaultCancelled) { + return; + } + + // Don't check if the event was already consumed because tab + // navigation should always work for better user experience. + + const { ShortcutUtils } = imports; + + switch (ShortcutUtils.getSystemActionForEvent(event)) { + case ShortcutUtils.CYCLE_TABS: + Services.telemetry.keyedScalarAdd( + "browser.ui.interaction.keyboard", + "ctrl-tab", + 1 + ); + Services.prefs.setBoolPref( + "browser.engagement.ctrlTab.has-used", + true + ); + if (this.tabs && this.handleCtrlTab) { + this.tabs.advanceSelectedTab(event.shiftKey ? -1 : 1, true); + event.preventDefault(); + } + break; + case ShortcutUtils.PREVIOUS_TAB: + if (this.tabs) { + this.tabs.advanceSelectedTab(-1, true); + event.preventDefault(); + } + break; + case ShortcutUtils.NEXT_TAB: + if (this.tabs) { + this.tabs.advanceSelectedTab(1, true); + event.preventDefault(); + } + break; + } + } + } + + customElements.define("tabbox", MozTabbox); + + class MozTabpanels extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this._tabbox = null; + this._selectedPanel = this.children.item(this.selectedIndex); + } + + get tabbox() { + // Memoize the result rather than replacing this getter, so that + // it can be reset if the parent changes. + if (this._tabbox) { + return this._tabbox; + } + + let parent = this.parentNode; + while (parent) { + if (parent.localName == "tabbox") { + break; + } + parent = parent.parentNode; + } + + return (this._tabbox = parent); + } + + set selectedIndex(val) { + if (val < 0 || val >= this.children.length) { + return val; + } + + let panel = this._selectedPanel; + this._selectedPanel = this.children[val]; + + if (this.getAttribute("async") != "true") { + this.setAttribute("selectedIndex", val); + } + + if (this._selectedPanel != panel) { + let event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + } + return val; + } + + get selectedIndex() { + let indexStr = this.getAttribute("selectedIndex"); + return indexStr ? parseInt(indexStr) : -1; + } + + set selectedPanel(val) { + let selectedIndex = -1; + for ( + let panel = val; + panel != null; + panel = panel.previousElementSibling + ) { + ++selectedIndex; + } + this.selectedIndex = selectedIndex; + return val; + } + + get selectedPanel() { + return this._selectedPanel; + } + + /** + * nsIDOMXULRelatedElement + */ + getRelatedElement(aTabPanelElm) { + if (!aTabPanelElm) { + return null; + } + + let tabboxElm = this.tabbox; + if (!tabboxElm) { + return null; + } + + let tabsElm = tabboxElm.tabs; + if (!tabsElm) { + return null; + } + + // Return tab element having 'linkedpanel' attribute equal to the id + // of the tab panel or the same index as the tab panel element. + let tabpanelIdx = Array.prototype.indexOf.call( + this.children, + aTabPanelElm + ); + if (tabpanelIdx == -1) { + return null; + } + + let tabElms = tabsElm.allTabs; + let tabElmFromIndex = tabElms[tabpanelIdx]; + + let tabpanelId = aTabPanelElm.id; + if (tabpanelId) { + for (let idx = 0; idx < tabElms.length; idx++) { + let tabElm = tabElms[idx]; + if (tabElm.linkedPanel == tabpanelId) { + return tabElm; + } + } + } + + return tabElmFromIndex; + } + } + + MozXULElement.implementCustomInterface(MozTabpanels, [ + Ci.nsIDOMXULRelatedElement, + ]); + customElements.define("tabpanels", MozTabpanels); + + MozElements.MozTab = class MozTab extends MozElements.BaseText { + static get markup() { + return ` + <hbox class="tab-middle box-inherit" flex="1"> + <image class="tab-icon" role="presentation"></image> + <label class="tab-text" flex="1" role="presentation"></label> + </hbox> + `; + } + + constructor() { + super(); + + this.addEventListener("mousedown", this); + this.addEventListener("keydown", this); + + this.arrowKeysShouldWrap = AppConstants.platform == "macosx"; + } + + static get inheritedAttributes() { + return { + ".tab-middle": "align,dir,pack,orient,selected,visuallyselected", + ".tab-icon": "validate,src=image", + ".tab-text": "value=label,accesskey,crop,disabled", + }; + } + + connectedCallback() { + if (!this._initialized) { + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + this._initialized = true; + } + } + + on_mousedown(event) { + if (event.button != 0 || this.disabled) { + return; + } + + this.parentNode.ariaFocusedItem = null; + + if (this == this.parentNode.selectedItem) { + // This tab is already selected and we will fall + // through to mousedown behavior which sets focus on the current tab, + // Only a click on an already selected tab should focus the tab itself. + return; + } + + let stopwatchid = this.parentNode.getAttribute("stopwatchid"); + if (stopwatchid) { + TelemetryStopwatch.start(stopwatchid); + } + + // Call this before setting the 'ignorefocus' attribute because this + // will pass on focus if the formerly selected tab was focused as well. + this.closest("tabs")._selectNewTab(this); + + var isTabFocused = false; + try { + isTabFocused = document.commandDispatcher.focusedElement == this; + } catch (e) {} + + // Set '-moz-user-focus' to 'ignore' so that PostHandleEvent() can't + // focus the tab; we only want tabs to be focusable by the mouse if + // they are already focused. After a short timeout we'll reset + // '-moz-user-focus' so that tabs can be focused by keyboard again. + if (!isTabFocused) { + this.setAttribute("ignorefocus", "true"); + setTimeout(tab => tab.removeAttribute("ignorefocus"), 0, this); + } + + if (stopwatchid) { + TelemetryStopwatch.finish(stopwatchid); + } + } + + on_keydown(event) { + if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return; + } + switch (event.keyCode) { + case KeyEvent.DOM_VK_LEFT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? -1 : 1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_RIGHT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? 1 : -1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_UP: + this.container.advanceSelectedTab(-1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_DOWN: + this.container.advanceSelectedTab(1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_HOME: + this.container._selectNewTab(this.container.allTabs[0]); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_END: { + let { allTabs } = this.container; + this.container._selectNewTab(allTabs[allTabs.length - 1], -1); + event.preventDefault(); + break; + } + } + } + + set value(val) { + this.setAttribute("value", val); + return val; + } + + get value() { + return this.getAttribute("value"); + } + + get control() { + var parent = this.parentNode; + return parent.localName == "tabs" ? parent : null; + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + + set _selected(val) { + if (val) { + this.setAttribute("selected", "true"); + this.setAttribute("visuallyselected", "true"); + } else { + this.removeAttribute("selected"); + this.removeAttribute("visuallyselected"); + } + + return val; + } + + set linkedPanel(val) { + this.setAttribute("linkedpanel", val); + } + + get linkedPanel() { + return this.getAttribute("linkedpanel"); + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozTab, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("tab", MozElements.MozTab); + + class TabsBase extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("DOMMouseScroll", event => { + if (Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling")) { + if (event.detail > 0) { + this.advanceSelectedTab(1, false); + } else { + this.advanceSelectedTab(-1, false); + } + event.stopPropagation(); + } + }); + } + + // to be called from derived class connectedCallback + baseConnect() { + this._tabbox = null; + this.ACTIVE_DESCENDANT_ID = + "keyboard-focused-tab-" + Math.trunc(Math.random() * 1000000); + + if (!this.hasAttribute("orient")) { + this.setAttribute("orient", "horizontal"); + } + + if (this.tabbox && this.tabbox.hasAttribute("selectedIndex")) { + let selectedIndex = parseInt(this.tabbox.getAttribute("selectedIndex")); + this.selectedIndex = selectedIndex > 0 ? selectedIndex : 0; + return; + } + + let children = this.allTabs; + let length = children.length; + for (var i = 0; i < length; i++) { + if (children[i].getAttribute("selected") == "true") { + this.selectedIndex = i; + return; + } + } + + var value = this.value; + if (value) { + this.value = value; + } else { + this.selectedIndex = 0; + } + } + + /** + * nsIDOMXULSelectControlElement + */ + get itemCount() { + return this.allTabs.length; + } + + set value(val) { + this.setAttribute("value", val); + var children = this.allTabs; + for (var c = children.length - 1; c >= 0; c--) { + if (children[c].value == val) { + this.selectedIndex = c; + break; + } + } + return val; + } + + get value() { + return this.getAttribute("value"); + } + + get tabbox() { + if (!this._tabbox) { + // Memoize the result in a field rather than replacing this property, + // so that it can be reset along with the binding. + this._tabbox = this.closest("tabbox"); + } + + return this._tabbox; + } + + set selectedIndex(val) { + var tab = this.getItemAtIndex(val); + if (tab) { + for (let otherTab of this.allTabs) { + if (otherTab != tab && otherTab.selected) { + otherTab._selected = false; + } + } + tab._selected = true; + + this.setAttribute("value", tab.value); + + let linkedPanel = this.getRelatedElement(tab); + if (linkedPanel) { + this.tabbox.setAttribute("selectedIndex", val); + + // This will cause an onselect event to fire for the tabpanel + // element. + this.tabbox.tabpanels.selectedPanel = linkedPanel; + } + } + return val; + } + + get selectedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return i; + } + } + return -1; + } + + set selectedItem(val) { + if (val && !val.selected) { + // The selectedIndex setter ignores invalid values + // such as -1 if |val| isn't one of our child nodes. + this.selectedIndex = this.getIndexOfItem(val); + } + return val; + } + + get selectedItem() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return tabs[i]; + } + } + return null; + } + + get ariaFocusedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id == this.ACTIVE_DESCENDANT_ID) { + return i; + } + } + return -1; + } + + set ariaFocusedItem(val) { + let setNewItem = val && this.getIndexOfItem(val) != -1; + let clearExistingItem = this.ariaFocusedItem && (!val || setNewItem); + if (clearExistingItem) { + let ariaFocusedItem = this.ariaFocusedItem; + ariaFocusedItem.classList.remove("keyboard-focused-tab"); + ariaFocusedItem.id = ""; + this.selectedItem.removeAttribute("aria-activedescendant"); + let evt = new CustomEvent("AriaFocus"); + this.selectedItem.dispatchEvent(evt); + } + + if (setNewItem) { + this.ariaFocusedItem = null; + val.id = this.ACTIVE_DESCENDANT_ID; + val.classList.add("keyboard-focused-tab"); + this.selectedItem.setAttribute( + "aria-activedescendant", + this.ACTIVE_DESCENDANT_ID + ); + let evt = new CustomEvent("AriaFocus"); + val.dispatchEvent(evt); + } + + return val; + } + + get ariaFocusedItem() { + return document.getElementById(this.ACTIVE_DESCENDANT_ID); + } + + /** + * nsIDOMXULRelatedElement + */ + getRelatedElement(aTabElm) { + if (!aTabElm) { + return null; + } + + let tabboxElm = this.tabbox; + if (!tabboxElm) { + return null; + } + + let tabpanelsElm = tabboxElm.tabpanels; + if (!tabpanelsElm) { + return null; + } + + // Get linked tab panel by 'linkedpanel' attribute on the given tab + // element. + let linkedPanelId = aTabElm.linkedPanel; + if (linkedPanelId) { + return this.ownerDocument.getElementById(linkedPanelId); + } + + // otherwise linked tabpanel element has the same index as the given + // tab element. + let tabElmIdx = this.getIndexOfItem(aTabElm); + return tabpanelsElm.children[tabElmIdx]; + } + + getIndexOfItem(item) { + return Array.prototype.indexOf.call(this.allTabs, item); + } + + getItemAtIndex(index) { + return this.allTabs[index] || null; + } + + /** + * Find an adjacent tab. + * + * @param {Node} startTab A <tab> element to start searching from. + * @param {Number} opts.direction 1 to search forward, -1 to search backward. + * @param {Boolean} opts.wrap If true, wrap around if the search reaches + * the end (or beginning) of the tab strip. + * @param {Boolean} opts.startWithAdjacent + * If true (which is the default), start + * searching from the next tab after (or + * before) startTab. If false, startTab may + * be returned if it passes the filter. + * @param {Boolean} opts.advance If false, start searching with startTab. If + * true, start searching with an adjacent tab. + * @param {Function} opts.filter A function to select which tabs to return. + * + * @return {Node | null} The next <tab> element or, if none exists, null. + */ + findNextTab(startTab, opts = {}) { + let { + direction = 1, + wrap = false, + startWithAdjacent = true, + filter = tab => true, + } = opts; + + let tab = startTab; + if (!startWithAdjacent && filter(tab)) { + return tab; + } + + let children = this.allTabs; + let i = children.indexOf(tab); + if (i < 0) { + return null; + } + + while (true) { + i += direction; + if (wrap) { + if (i < 0) { + i = children.length - 1; + } else if (i >= children.length) { + i = 0; + } + } else if (i < 0 || i >= children.length) { + return null; + } + + tab = children[i]; + if (tab == startTab) { + return null; + } + if (filter(tab)) { + return tab; + } + } + } + + _selectNewTab(aNewTab, aFallbackDir, aWrap) { + this.ariaFocusedItem = null; + + aNewTab = this.findNextTab(aNewTab, { + direction: aFallbackDir, + wrap: aWrap, + startWithAdjacent: false, + filter: tab => + !tab.hidden && !tab.disabled && this._canAdvanceToTab(tab), + }); + + var isTabFocused = false; + try { + isTabFocused = + document.commandDispatcher.focusedElement == this.selectedItem; + } catch (e) {} + this.selectedItem = aNewTab; + if (isTabFocused) { + aNewTab.focus(); + } else if (this.getAttribute("setfocus") != "false") { + let selectedPanel = this.tabbox.selectedPanel; + document.commandDispatcher.advanceFocusIntoSubtree(selectedPanel); + + // Make sure that the focus doesn't move outside the tabbox + if (this.tabbox) { + try { + let el = document.commandDispatcher.focusedElement; + while (el && el != this.tabbox.tabpanels) { + if (el == this.tabbox || el == selectedPanel) { + return; + } + el = el.parentNode; + } + aNewTab.focus(); + } catch (e) {} + } + } + } + + _canAdvanceToTab(aTab) { + return true; + } + + advanceSelectedTab(aDir, aWrap) { + let startTab = this.ariaFocusedItem || this.selectedItem; + let newTab = this.findNextTab(startTab, { + direction: aDir, + wrap: aWrap, + }); + if (newTab && newTab != startTab) { + this._selectNewTab(newTab, aDir, aWrap); + } + } + + appendItem(label, value) { + var tab = document.createXULElement("tab"); + tab.setAttribute("label", label); + tab.setAttribute("value", value); + this.appendChild(tab); + return tab; + } + } + + MozXULElement.implementCustomInterface(TabsBase, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULRelatedElement, + ]); + + MozElements.TabsBase = TabsBase; + + class MozTabs extends TabsBase { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + let start = MozXULElement.parseXULToFragment( + `<spacer class="tabs-left"/>` + ); + this.insertBefore(start, this.firstChild); + + let end = MozXULElement.parseXULToFragment( + `<spacer class="tabs-right" flex="1"/>` + ); + this.insertBefore(end, null); + + this.baseConnect(); + } + + // Accessor for tabs. This element has spacers as the first and + // last elements and <tab>s are everything in between. + get allTabs() { + let children = Array.from(this.children); + return children.splice(1, children.length - 2); + } + + appendChild(tab) { + // insert before the end spacer. + this.insertBefore(tab, this.lastChild); + } + } + + customElements.define("tabs", MozTabs); +} diff --git a/toolkit/content/widgets/text.js b/toolkit/content/widgets/text.js new file mode 100644 index 0000000000..402c08b284 --- /dev/null +++ b/toolkit/content/widgets/text.js @@ -0,0 +1,394 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const MozXULTextElement = MozElements.MozElementMixin(XULTextElement); + + let gInsertSeparator = false; + let gAlwaysAppendAccessKey = false; + let gUnderlineAccesskey = + Services.prefs.getIntPref("ui.key.menuAccessKey") != 0; + if (gUnderlineAccesskey) { + try { + const nsIPrefLocalizedString = Ci.nsIPrefLocalizedString; + const prefNameInsertSeparator = + "intl.menuitems.insertseparatorbeforeaccesskeys"; + const prefNameAlwaysAppendAccessKey = + "intl.menuitems.alwaysappendaccesskeys"; + + let val = Services.prefs.getComplexValue( + prefNameInsertSeparator, + nsIPrefLocalizedString + ).data; + gInsertSeparator = val == "true"; + + val = Services.prefs.getComplexValue( + prefNameAlwaysAppendAccessKey, + nsIPrefLocalizedString + ).data; + gAlwaysAppendAccessKey = val == "true"; + } catch (e) { + gInsertSeparator = gAlwaysAppendAccessKey = true; + } + } + + class MozTextLabel extends MozXULTextElement { + constructor() { + super(); + this._lastFormattedAccessKey = null; + this.addEventListener("click", this._onClick); + } + + static get observedAttributes() { + return ["accesskey"]; + } + + set textContent(val) { + super.textContent = val; + this._lastFormattedAccessKey = null; + this.formatAccessKey(); + } + + get textContent() { + return super.textContent; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this.isConnectedAndReady || oldValue == newValue) { + return; + } + + // Note that this is only happening when "accesskey" attribute change: + this.formatAccessKey(); + } + + _onClick(event) { + let controlElement = this.labeledControlElement; + if (!controlElement || this.disabled) { + return; + } + controlElement.focus(); + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + if (controlElement.namespaceURI != XUL_NS) { + return; + } + + if ( + (controlElement.localName == "checkbox" || + controlElement.localName == "radio") && + controlElement.getAttribute("disabled") == "true" + ) { + return; + } + + if (controlElement.localName == "checkbox") { + controlElement.checked = !controlElement.checked; + } else if (controlElement.localName == "radio") { + controlElement.control.selectedItem = controlElement; + } + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.formatAccessKey(); + } + + set accessKey(val) { + this.setAttribute("accesskey", val); + var control = this.labeledControlElement; + if (control) { + control.setAttribute("accesskey", val); + } + } + + get accessKey() { + let accessKey = this.getAttribute("accesskey"); + return accessKey ? accessKey[0] : null; + } + + get labeledControlElement() { + let control = this.control; + return control ? document.getElementById(control) : null; + } + + set control(val) { + this.setAttribute("control", val); + } + + get control() { + return this.getAttribute("control"); + } + + // This is used to match the rendering of accesskeys from nsTextBoxFrame.cpp (i.e. when the + // label uses [value]). So this is just for when we have textContent. + formatAccessKey() { + // Skip doing any DOM manipulation whenever possible: + let accessKey = this.accessKey; + if ( + !gUnderlineAccesskey || + !this.isConnectedAndReady || + this._lastFormattedAccessKey == accessKey || + !this.textContent + ) { + return; + } + this._lastFormattedAccessKey = accessKey; + if (this.accessKeySpan) { + // Clear old accesskey + mergeElement(this.accessKeySpan); + this.accessKeySpan = null; + } + + if (this.hiddenColon) { + mergeElement(this.hiddenColon); + this.hiddenColon = null; + } + + if (this.accessKeyParens) { + this.accessKeyParens.remove(); + this.accessKeyParens = null; + } + + // If we used to have an accessKey but not anymore, we're done here + if (!accessKey) { + return; + } + + let labelText = this.textContent; + let accessKeyIndex = -1; + if (!gAlwaysAppendAccessKey) { + accessKeyIndex = labelText.indexOf(accessKey); + if (accessKeyIndex < 0) { + // Try again in upper case + accessKeyIndex = labelText + .toUpperCase() + .indexOf(accessKey.toUpperCase()); + } + } else if (labelText.endsWith(`(${accessKey.toUpperCase()})`)) { + accessKeyIndex = labelText.length - (1 + accessKey.length); // = index of accessKey. + } + + const HTML_NS = "http://www.w3.org/1999/xhtml"; + this.accessKeySpan = document.createElementNS(HTML_NS, "span"); + this.accessKeySpan.className = "accesskey"; + + // Note that if you change the following code, see the comment of + // nsTextBoxFrame::UpdateAccessTitle. + + // If accesskey is in the string, underline it: + if (accessKeyIndex >= 0) { + wrapChar(this, this.accessKeySpan, accessKeyIndex); + return; + } + + // If accesskey is not in string, append in parentheses + // If end is colon, we should insert before colon. + // i.e., "label:" -> "label(X):" + let colonHidden = false; + if (/:$/.test(labelText)) { + labelText = labelText.slice(0, -1); + this.hiddenColon = document.createElementNS(HTML_NS, "span"); + this.hiddenColon.className = "hiddenColon"; + this.hiddenColon.style.display = "none"; + // Hide the last colon by using span element. + // I.e., label<span style="display:none;">:</span> + wrapChar(this, this.hiddenColon, labelText.length); + colonHidden = true; + } + // If end is space(U+20), + // we should not add space before parentheses. + let endIsSpace = false; + if (/ $/.test(labelText)) { + endIsSpace = true; + } + + this.accessKeyParens = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "span" + ); + this.appendChild(this.accessKeyParens); + if (gInsertSeparator && !endIsSpace) { + this.accessKeyParens.textContent = " ("; + } else { + this.accessKeyParens.textContent = "("; + } + this.accessKeySpan.textContent = accessKey.toUpperCase(); + this.accessKeyParens.appendChild(this.accessKeySpan); + if (!colonHidden) { + this.accessKeyParens.appendChild(document.createTextNode(")")); + } else { + this.accessKeyParens.appendChild(document.createTextNode("):")); + } + } + } + + customElements.define("label", MozTextLabel); + + function mergeElement(element) { + // If the element has been removed already, return: + if (!element.isConnected) { + return; + } + if (element.previousSibling instanceof Text) { + element.previousSibling.appendData(element.textContent); + } else { + element.parentNode.insertBefore(element.firstChild, element); + } + element.remove(); + } + + function wrapChar(parent, element, index) { + let treeWalker = document.createNodeIterator( + parent, + NodeFilter.SHOW_TEXT, + null + ); + let node = treeWalker.nextNode(); + while (index >= node.length) { + index -= node.length; + node = treeWalker.nextNode(); + } + if (index) { + node = node.splitText(index); + } + + node.parentNode.insertBefore(element, node); + if (node.length > 1) { + node.splitText(1); + } + element.appendChild(node); + } + + class MozTextLink extends MozXULTextElement { + constructor() { + super(); + + this.addEventListener( + "click", + event => { + if (event.button == 0 || event.button == 1) { + this.open(event); + } + }, + true + ); + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + this.click(); + }); + } + + connectedCallback() { + this.classList.add("text-link"); + } + + set href(val) { + this.setAttribute("href", val); + return val; + } + + get href() { + return this.getAttribute("href"); + } + + open(aEvent) { + var href = this.href; + if (!href || this.disabled || aEvent.defaultPrevented) { + return; + } + + var uri = null; + try { + const nsISSM = Ci.nsIScriptSecurityManager; + const secMan = Cc["@mozilla.org/scriptsecuritymanager;1"].getService( + nsISSM + ); + + uri = Services.io.newURI(href); + + let principal; + if (this.getAttribute("useoriginprincipal") == "true") { + principal = this.nodePrincipal; + } else { + principal = secMan.createNullPrincipal({}); + } + try { + secMan.checkLoadURIWithPrincipal( + principal, + uri, + nsISSM.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (ex) { + var msg = + "Error: Cannot open a " + + uri.scheme + + ": link using \ + the text-link binding."; + Cu.reportError(msg); + return; + } + + const cID = "@mozilla.org/uriloader/external-protocol-service;1"; + const nsIEPS = Ci.nsIExternalProtocolService; + var protocolSvc = Cc[cID].getService(nsIEPS); + + // if the scheme is not an exposed protocol, then opening this link + // should be deferred to the system's external protocol handler + if (!protocolSvc.isExposedProtocol(uri.scheme)) { + protocolSvc.loadURI(uri, principal); + aEvent.preventDefault(); + return; + } + } catch (ex) { + Cu.reportError(ex); + } + + aEvent.preventDefault(); + href = uri ? uri.spec : href; + + // Try handing off the link to the host application, e.g. for + // opening it in a tabbed browser. + var linkHandled = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + linkHandled.data = false; + let { shiftKey, ctrlKey, metaKey, altKey, button } = aEvent; + let data = { shiftKey, ctrlKey, metaKey, altKey, button, href }; + Services.obs.notifyObservers( + linkHandled, + "handle-xul-text-link", + JSON.stringify(data) + ); + if (linkHandled.data) { + return; + } + + // otherwise, fall back to opening the anchor directly + var win = window; + if (window.isChromeWindow) { + while (win.opener && !win.opener.closed) { + win = win.opener; + } + } + win.open(href, "_blank", "noopener"); + } + } + + customElements.define("text-link", MozTextLink, { extends: "label" }); +} diff --git a/toolkit/content/widgets/timekeeper.js b/toolkit/content/widgets/timekeeper.js new file mode 100644 index 0000000000..9e53ce602f --- /dev/null +++ b/toolkit/content/widgets/timekeeper.js @@ -0,0 +1,445 @@ +/* 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"; + +/** + * TimeKeeper keeps track of the time states. Given min, max, step, and + * format (12/24hr), TimeKeeper will determine the ranges of possible + * selections, and whether or not the current time state is out of range + * or off step. + * + * @param {Object} props + * { + * {Date} min + * {Date} max + * {Number} step + * {String} format: Either "12" or "24" + * } + */ +function TimeKeeper(props) { + this.props = props; + this.state = { time: new Date(0), ranges: {} }; +} + +{ + const DAY_PERIOD_IN_HOURS = 12, + SECOND_IN_MS = 1000, + MINUTE_IN_MS = 60000, + HOUR_IN_MS = 3600000, + DAY_PERIOD_IN_MS = 43200000, + DAY_IN_MS = 86400000, + TIME_FORMAT_24 = "24"; + + TimeKeeper.prototype = { + /** + * Getters for different time units. + * @return {Number} + */ + get hour() { + return this.state.time.getUTCHours(); + }, + get minute() { + return this.state.time.getUTCMinutes(); + }, + get second() { + return this.state.time.getUTCSeconds(); + }, + get millisecond() { + return this.state.time.getUTCMilliseconds(); + }, + get dayPeriod() { + // 0 stands for AM and 12 for PM + return this.state.time.getUTCHours() < DAY_PERIOD_IN_HOURS + ? 0 + : DAY_PERIOD_IN_HOURS; + }, + + /** + * Get the ranges of different time units. + * @return {Object} + * { + * {Array<Number>} dayPeriod + * {Array<Number>} hours + * {Array<Number>} minutes + * {Array<Number>} seconds + * {Array<Number>} milliseconds + * } + */ + get ranges() { + return this.state.ranges; + }, + + /** + * Set new time, check if the current state is valid, and set ranges. + * + * @param {Object} timeState: The new time + * { + * {Number} hour [optional] + * {Number} minute [optional] + * {Number} second [optional] + * {Number} millisecond [optional] + * } + */ + setState(timeState) { + const { min, max } = this.props; + const { hour, minute, second, millisecond } = timeState; + + if (hour != undefined) { + this.state.time.setUTCHours(hour); + } + if (minute != undefined) { + this.state.time.setUTCMinutes(minute); + } + if (second != undefined) { + this.state.time.setUTCSeconds(second); + } + if (millisecond != undefined) { + this.state.time.setUTCMilliseconds(millisecond); + } + + this.state.isOffStep = this._isOffStep(this.state.time); + this.state.isOutOfRange = this.state.time < min || this.state.time > max; + this.state.isInvalid = this.state.isOutOfRange || this.state.isOffStep; + + this._setRanges(this.dayPeriod, this.hour, this.minute, this.second); + }, + + /** + * Set day-period (AM/PM) + * @param {Number} dayPeriod: 0 as AM, 12 as PM + */ + setDayPeriod(dayPeriod) { + if (dayPeriod == this.dayPeriod) { + return; + } + + if (dayPeriod == 0) { + this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS }); + } else { + this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }); + } + }, + + /** + * Set hour in 24hr format (0 ~ 23) + * @param {Number} hour + */ + setHour(hour) { + this.setState({ hour }); + }, + + /** + * Set minute (0 ~ 59) + * @param {Number} minute + */ + setMinute(minute) { + this.setState({ minute }); + }, + + /** + * Set second (0 ~ 59) + * @param {Number} second + */ + setSecond(second) { + this.setState({ second }); + }, + + /** + * Set millisecond (0 ~ 999) + * @param {Number} millisecond + */ + setMillisecond(millisecond) { + this.setState({ millisecond }); + }, + + /** + * Calculate the range of possible choices for each time unit. + * Reuse the old result if the input has not changed. + * + * @param {Number} dayPeriod + * @param {Number} hour + * @param {Number} minute + * @param {Number} second + */ + _setRanges(dayPeriod, hour, minute, second) { + this.state.ranges.dayPeriod = + this.state.ranges.dayPeriod || this._getDayPeriodRange(); + + if (this.state.dayPeriod != dayPeriod) { + this.state.ranges.hours = this._getHoursRange(dayPeriod); + } + + if (this.state.hour != hour) { + this.state.ranges.minutes = this._getMinutesRange(hour); + } + + if (this.state.hour != hour || this.state.minute != minute) { + this.state.ranges.seconds = this._getSecondsRange(hour, minute); + } + + if ( + this.state.hour != hour || + this.state.minute != minute || + this.state.second != second + ) { + this.state.ranges.milliseconds = this._getMillisecondsRange( + hour, + minute, + second + ); + } + + // Save the time states for comparison. + this.state.dayPeriod = dayPeriod; + this.state.hour = hour; + this.state.minute = minute; + this.state.second = second; + }, + + /** + * Get the AM/PM range. Return an empty array if in 24hr mode. + * + * @return {Array<Number>} + */ + _getDayPeriodRange() { + if (this.props.format == TIME_FORMAT_24) { + return []; + } + + const start = 0; + const end = DAY_IN_MS - 1; + const minStep = DAY_PERIOD_IN_MS; + const formatter = time => + new Date(time).getUTCHours() < DAY_PERIOD_IN_HOURS + ? 0 + : DAY_PERIOD_IN_HOURS; + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the hours range. + * + * @param {Number} dayPeriod + * @return {Array<Number>} + */ + _getHoursRange(dayPeriod) { + const { format } = this.props; + const start = format == "24" ? 0 : dayPeriod * HOUR_IN_MS; + const end = format == "24" ? DAY_IN_MS - 1 : start + DAY_PERIOD_IN_MS - 1; + const minStep = HOUR_IN_MS; + const formatter = time => new Date(time).getUTCHours(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the minutes range + * + * @param {Number} hour + * @return {Array<Number>} + */ + _getMinutesRange(hour) { + const start = hour * HOUR_IN_MS; + const end = start + HOUR_IN_MS - 1; + const minStep = MINUTE_IN_MS; + const formatter = time => new Date(time).getUTCMinutes(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the seconds range + * + * @param {Number} hour + * @param {Number} minute + * @return {Array<Number>} + */ + _getSecondsRange(hour, minute) { + const start = hour * HOUR_IN_MS + minute * MINUTE_IN_MS; + const end = start + MINUTE_IN_MS - 1; + const minStep = SECOND_IN_MS; + const formatter = time => new Date(time).getUTCSeconds(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the milliseconds range + * @param {Number} hour + * @param {Number} minute + * @param {Number} second + * @return {Array<Number>} + */ + _getMillisecondsRange(hour, minute, second) { + const start = + hour * HOUR_IN_MS + minute * MINUTE_IN_MS + second * SECOND_IN_MS; + const end = start + SECOND_IN_MS - 1; + const minStep = 1; + const formatter = time => new Date(time).getUTCMilliseconds(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Calculate the range of possible steps. + * + * @param {Number} startValue: Start time in ms + * @param {Number} endValue: End time in ms + * @param {Number} minStep: Smallest step in ms for the time unit + * @param {Function} formatter: Outputs time in a particular format + * @return {Array<Object>} + * { + * {Number} value + * {Boolean} enabled + * } + */ + _getSteps(startValue, endValue, minStep, formatter) { + const { min, max, step } = this.props; + // The timeStep should be big enough so that there won't be + // duplications. Ex: minimum step for minute should be 60000ms, + // if smaller than that, next step might return the same minute. + const timeStep = Math.max(minStep, step); + + // Make sure the starting point and end point is not off step + let time = + min.valueOf() + + Math.ceil((startValue - min.valueOf()) / timeStep) * timeStep; + let maxValue = + min.valueOf() + + Math.floor((max.valueOf() - min.valueOf()) / step) * step; + let steps = []; + + // Increment by timeStep until reaching the end of the range. + while (time <= endValue) { + steps.push({ + value: formatter(time), + // Check if the value is within the min and max. If it's out of range, + // also check for the case when minStep is too large, and has stepped out + // of range when it should be enabled. + enabled: + (time >= min.valueOf() && time <= max.valueOf()) || + (time > maxValue && + startValue <= maxValue && + endValue >= maxValue && + formatter(time) == formatter(maxValue)), + }); + time += timeStep; + } + + return steps; + }, + + /** + * A generic function for stepping up or down from a value of a range. + * It stops at the upper and lower limits. + * + * @param {Number} current: The current value + * @param {Number} offset: The offset relative to current value + * @param {Array<Object>} range: List of possible steps + * @return {Number} The new value + */ + _step(current, offset, range) { + const index = range.findIndex(step => step.value == current); + const newIndex = + offset > 0 + ? Math.min(index + offset, range.length - 1) + : Math.max(index + offset, 0); + return range[newIndex].value; + }, + + /** + * Step up or down AM/PM + * + * @param {Number} offset + */ + stepDayPeriodBy(offset) { + const current = this.dayPeriod; + const dayPeriod = this._step( + current, + offset, + this.state.ranges.dayPeriod + ); + + if (current != dayPeriod) { + this.hour < DAY_PERIOD_IN_HOURS + ? this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }) + : this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS }); + } + }, + + /** + * Step up or down hours + * + * @param {Number} offset + */ + stepHourBy(offset) { + const current = this.hour; + const hour = this._step(current, offset, this.state.ranges.hours); + + if (current != hour) { + this.setState({ hour }); + } + }, + + /** + * Step up or down minutes + * + * @param {Number} offset + */ + stepMinuteBy(offset) { + const current = this.minute; + const minute = this._step(current, offset, this.state.ranges.minutes); + + if (current != minute) { + this.setState({ minute }); + } + }, + + /** + * Step up or down seconds + * + * @param {Number} offset + */ + stepSecondBy(offset) { + const current = this.second; + const second = this._step(current, offset, this.state.ranges.seconds); + + if (current != second) { + this.setState({ second }); + } + }, + + /** + * Step up or down milliseconds + * + * @param {Number} offset + */ + stepMillisecondBy(offset) { + const current = this.milliseconds; + const millisecond = this._step( + current, + offset, + this.state.ranges.millisecond + ); + + if (current != millisecond) { + this.setState({ millisecond }); + } + }, + + /** + * Checks if the time state is off step. + * + * @param {Date} time + * @return {Boolean} + */ + _isOffStep(time) { + const { min, step } = this.props; + + return (time.valueOf() - min.valueOf()) % step != 0; + }, + }; +} diff --git a/toolkit/content/widgets/timepicker.js b/toolkit/content/widgets/timepicker.js new file mode 100644 index 0000000000..645391ae73 --- /dev/null +++ b/toolkit/content/widgets/timepicker.js @@ -0,0 +1,288 @@ +/* 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/. */ + +/* import-globals-from timekeeper.js */ +/* import-globals-from spinner.js */ + +"use strict"; + +function TimePicker(context) { + this.context = context; + this._attachEventListeners(); +} + +{ + const DAY_PERIOD_IN_HOURS = 12, + DAY_IN_MS = 86400000; + + TimePicker.prototype = { + /** + * Initializes the time picker. Set the default states and properties. + * @param {Object} props + * { + * {Number} hour [optional]: Hour in 24 hours format (0~23), default is current hour + * {Number} minute [optional]: Minute (0~59), default is current minute + * {Number} min: Minimum time, in ms + * {Number} max: Maximum time, in ms + * {Number} step: Step size in ms + * {String} format [optional]: "12" for 12 hours, "24" for 24 hours format + * {String} locale [optional]: User preferred locale + * } + */ + init(props) { + this.props = props || {}; + this._setDefaultState(); + this._createComponents(); + this._setComponentStates(); + }, + + /* + * Set initial time states. If there's no hour & minute, it will + * use the current time. The Time module keeps track of the time states, + * and calculates the valid options given the time, min, max, step, + * and format (12 or 24). + */ + _setDefaultState() { + const { hour, minute, min, max, step, format } = this.props; + const now = new Date(); + + let timerHour = hour == undefined ? now.getHours() : hour; + let timerMinute = minute == undefined ? now.getMinutes() : minute; + let timeKeeper = new TimeKeeper({ + min: new Date(Number.isNaN(min) ? 0 : min), + max: new Date(Number.isNaN(max) ? DAY_IN_MS - 1 : max), + step, + format: format || "12", + }); + timeKeeper.setState({ hour: timerHour, minute: timerMinute }); + + this.state = { timeKeeper }; + }, + + /** + * Initalize the spinner components. + */ + _createComponents() { + const { locale, format } = this.props; + const { timeKeeper } = this.state; + + const wrapSetValueFn = setTimeFunction => { + return value => { + setTimeFunction(value); + this._setComponentStates(); + this._dispatchState(); + }; + }; + const numberFormat = new Intl.NumberFormat(locale).format; + + this.components = { + hour: new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setHour(value); + this.state.isHourSet = true; + }), + getDisplayString: hour => { + if (format == "24") { + return numberFormat(hour); + } + // Hour 0 in 12 hour format is displayed as 12. + const hourIn12 = hour % DAY_PERIOD_IN_HOURS; + return hourIn12 == 0 ? numberFormat(12) : numberFormat(hourIn12); + }, + }, + this.context + ), + minute: new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setMinute(value); + this.state.isMinuteSet = true; + }), + getDisplayString: minute => numberFormat(minute), + }, + this.context + ), + }; + + this._insertLayoutElement({ + tag: "div", + textContent: ":", + className: "colon", + insertBefore: this.components.minute.elements.container, + }); + + // The AM/PM spinner is only available in 12hr mode + // TODO: Replace AM & PM string with localized string + if (format == "12") { + this.components.dayPeriod = new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setDayPeriod(value); + this.state.isDayPeriodSet = true; + }), + getDisplayString: dayPeriod => (dayPeriod == 0 ? "AM" : "PM"), + hideButtons: true, + }, + this.context + ); + + this._insertLayoutElement({ + tag: "div", + className: "spacer", + insertBefore: this.components.dayPeriod.elements.container, + }); + } + }, + + /** + * Insert element for layout purposes. + * + * @param {Object} + * { + * {String} tag: The tag to create + * {DOMElement} insertBefore: The DOM node to insert before + * {String} className [optional]: Class name + * {String} textContent [optional]: Text content + * } + */ + _insertLayoutElement({ tag, insertBefore, className, textContent }) { + let el = document.createElement(tag); + el.textContent = textContent; + el.className = className; + this.context.insertBefore(el, insertBefore); + }, + + /** + * Set component states. + */ + _setComponentStates() { + const { timeKeeper, isHourSet, isMinuteSet, isDayPeriodSet } = this.state; + const isInvalid = timeKeeper.state.isInvalid; + // Value is set to min if it's first opened and time state is invalid + const setToMinValue = + !isHourSet && !isMinuteSet && !isDayPeriodSet && isInvalid; + + this.components.hour.setState({ + value: setToMinValue + ? timeKeeper.ranges.hours[0].value + : timeKeeper.hour, + items: timeKeeper.ranges.hours, + isInfiniteScroll: true, + isValueSet: isHourSet, + isInvalid, + }); + + this.components.minute.setState({ + value: setToMinValue + ? timeKeeper.ranges.minutes[0].value + : timeKeeper.minute, + items: timeKeeper.ranges.minutes, + isInfiniteScroll: true, + isValueSet: isMinuteSet, + isInvalid, + }); + + // The AM/PM spinner is only available in 12hr mode + if (this.props.format == "12") { + this.components.dayPeriod.setState({ + value: setToMinValue + ? timeKeeper.ranges.dayPeriod[0].value + : timeKeeper.dayPeriod, + items: timeKeeper.ranges.dayPeriod, + isInfiniteScroll: false, + isValueSet: isDayPeriodSet, + isInvalid, + }); + } + }, + + /** + * Dispatch CustomEvent to pass the state of picker to the panel. + */ + _dispatchState() { + const { hour, minute } = this.state.timeKeeper; + const { isHourSet, isMinuteSet, isDayPeriodSet } = this.state; + // The panel is listening to window for postMessage event, so we + // do postMessage to itself to send data to input boxes. + window.postMessage( + { + name: "PickerPopupChanged", + detail: { + hour, + minute, + isHourSet, + isMinuteSet, + isDayPeriodSet, + }, + }, + "*" + ); + }, + _attachEventListeners() { + window.addEventListener("message", this); + document.addEventListener("mousedown", this); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setCapture(); + break; + } + } + }, + + /** + * Handle postMessage events. + * + * @param {Event} event + */ + handleMessage(event) { + switch (event.data.name) { + case "PickerSetValue": { + this.set(event.data.detail); + break; + } + case "PickerInit": { + this.init(event.data.detail); + break; + } + } + }, + + /** + * Set the time state and update the components with the new state. + * + * @param {Object} timeState + * { + * {Number} hour [optional] + * {Number} minute [optional] + * {Number} second [optional] + * {Number} millisecond [optional] + * } + */ + set(timeState) { + if (timeState.hour != undefined) { + this.state.isHourSet = true; + } + if (timeState.minute != undefined) { + this.state.isMinuteSet = true; + } + this.state.timeKeeper.setState(timeState); + this._setComponentStates(); + }, + }; +} diff --git a/toolkit/content/widgets/toolbarbutton.js b/toolkit/content/widgets/toolbarbutton.js new file mode 100644 index 0000000000..6b4f5449e5 --- /dev/null +++ b/toolkit/content/widgets/toolbarbutton.js @@ -0,0 +1,227 @@ +/* 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 is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const KEEP_CHILDREN = new Set([ + "observes", + "template", + "menupopup", + "panel", + "tooltip", + ]); + + window.addEventListener( + "popupshowing", + e => { + if (e.originalTarget.ownerDocument != document) { + return; + } + + e.originalTarget.setAttribute("hasbeenopened", "true"); + for (let el of e.originalTarget.querySelectorAll("toolbarbutton")) { + el.render(); + } + }, + { capture: true } + ); + + class MozToolbarbutton extends MozElements.ButtonBase { + static get inheritedAttributes() { + // Note: if you remove 'wrap' or 'label' from the inherited attributes, + // you'll need to add them to observedAttributes. + return { + ".toolbarbutton-icon": + "validate,src=image,label,type,consumeanchor,triggeringprincipal=iconloadingprincipal", + ".toolbarbutton-text": "accesskey,crop,dragover-top,wrap", + ".toolbarbutton-menu-dropmarker": "disabled,label", + + ".toolbarbutton-badge": "text=badge,style=badgeStyle", + }; + } + + static get fragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <image class="toolbarbutton-icon"></image> + <label class="toolbarbutton-text" crop="right" flex="1"></label> + `), + true + ); + Object.defineProperty(this, "fragment", { value: frag }); + return frag; + } + + static get badgedFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <stack class="toolbarbutton-badge-stack"> + <image class="toolbarbutton-icon"/> + <html:label class="toolbarbutton-badge"/> + </stack> + <label class="toolbarbutton-text" crop="right" flex="1"/> + `), + true + ); + Object.defineProperty(this, "badgedFragment", { value: frag }); + return frag; + } + + static get dropmarkerFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <dropmarker type="menu" class="toolbarbutton-menu-dropmarker"></dropmarker> + `), + true + ); + Object.defineProperty(this, "dropmarkerFragment", { value: frag }); + return frag; + } + + get _hasRendered() { + return this.querySelector(":scope > .toolbarbutton-text") != null; + } + + get _textNode() { + let node = this.getElementForAttrInheritance(".toolbarbutton-text"); + if (node) { + Object.defineProperty(this, "_textNode", { value: node }); + } + return node; + } + + _setLabel() { + let label = this.getAttribute("label") || ""; + let hasLabel = this.hasAttribute("label"); + if (this.getAttribute("wrap") == "true") { + this._textNode.removeAttribute("value"); + this._textNode.textContent = label; + } else { + this._textNode.textContent = ""; + if (hasLabel) { + this._textNode.setAttribute("value", label); + } else { + this._textNode.removeAttribute("value"); + } + } + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue || !this.initializedAttributeInheritance) { + return; + } + // Deal with single/multiline label inheritance: + if (name == "label" || name == "wrap") { + this._setLabel(); + } + // The normal implementation will deal with everything else. + super.attributeChangedCallback(name, oldValue, newValue); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + // Defer creating DOM elements for content inside popups. + // These will be added in the popupshown handler above. + let panel = this.closest("panel"); + if (panel && !panel.hasAttribute("hasbeenopened")) { + return; + } + + this.render(); + } + + render() { + if (this._hasRendered) { + return; + } + + let badged = this.getAttribute("badged") == "true"; + + if (badged) { + let moveChildren = []; + for (let child of this.children) { + if (!KEEP_CHILDREN.has(child.tagName)) { + moveChildren.push(child); + } + } + + this.appendChild(this.constructor.badgedFragment.cloneNode(true)); + + if (this.hasAttribute("wantdropmarker")) { + this.appendChild(this.constructor.dropmarkerFragment.cloneNode(true)); + } + + if (moveChildren.length) { + let { badgeStack, icon } = this; + for (let child of moveChildren) { + badgeStack.insertBefore(child, icon); + } + } + } else { + let moveChildren = []; + for (let child of this.children) { + if (!KEEP_CHILDREN.has(child.tagName) && child.tagName != "box") { + // XBL toolbarbutton doesn't insert any anonymous content + // if it has a child of any other type + return; + } + + if (child.tagName == "box") { + moveChildren.push(child); + } + } + + this.appendChild(this.constructor.fragment.cloneNode(true)); + + if (this.hasAttribute("wantdropmarker")) { + this.appendChild(this.constructor.dropmarkerFragment.cloneNode(true)); + } + + // XBL toolbarbutton explicitly places any <box> children + // right before the menu marker. + for (let child of moveChildren) { + this.insertBefore(child, this.lastChild); + } + } + + this.initializeAttributeInheritance(); + this._setLabel(); + } + + get icon() { + return this.querySelector(".toolbarbutton-icon"); + } + + get badgeLabel() { + return this.querySelector(".toolbarbutton-badge"); + } + + get badgeStack() { + return this.querySelector(".toolbarbutton-badge-stack"); + } + + get multilineLabel() { + if (this.getAttribute("wrap") == "true") { + return this._textNode; + } + return null; + } + + get dropmarker() { + return this.querySelector(".toolbarbutton-menu-dropmarker"); + } + + get menupopup() { + return this.querySelector("menupopup"); + } + } + + customElements.define("toolbarbutton", MozToolbarbutton); +} diff --git a/toolkit/content/widgets/tree.js b/toolkit/content/widgets/tree.js new file mode 100644 index 0000000000..a8639f8029 --- /dev/null +++ b/toolkit/content/widgets/tree.js @@ -0,0 +1,1644 @@ +/* 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/. */ + +/* globals XULTreeElement */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + class MozTreeChildren extends MozElements.BaseControl { + constructor() { + super(); + + /** + * If there is no modifier key, we select on mousedown, not + * click, so that drags work correctly. + */ + this.addEventListener("mousedown", event => { + if (this.parentNode.disabled) { + return; + } + if ( + ((!event.getModifierState("Accel") || + !this.parentNode.pageUpOrDownMovesSelection) && + !event.shiftKey && + !event.metaKey) || + this.parentNode.view.selection.single + ) { + var b = this.parentNode; + var cell = b.getCellAt(event.clientX, event.clientY); + var view = this.parentNode.view; + + // save off the last selected row + this._lastSelectedRow = cell.row; + + if (cell.row == -1) { + return; + } + + if (cell.childElt == "twisty") { + return; + } + + if (cell.col && event.button == 0) { + if (cell.col.cycler) { + view.cycleCell(cell.row, cell.col); + return; + } else if (cell.col.type == window.TreeColumn.TYPE_CHECKBOX) { + if ( + this.parentNode.editable && + cell.col.editable && + view.isEditable(cell.row, cell.col) + ) { + var value = view.getCellValue(cell.row, cell.col); + value = value == "true" ? "false" : "true"; + view.setCellValue(cell.row, cell.col, value); + return; + } + } + } + + if (!view.selection.isSelected(cell.row)) { + view.selection.select(cell.row); + b.ensureRowIsVisible(cell.row); + } + } + }); + + /** + * On a click (up+down on the same item), deselect everything + * except this item. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + if (this.parentNode.disabled) { + return; + } + var b = this.parentNode; + var cell = b.getCellAt(event.clientX, event.clientY); + var view = this.parentNode.view; + + if (cell.row == -1) { + return; + } + + if (cell.childElt == "twisty") { + if ( + view.selection.currentIndex >= 0 && + view.isContainerOpen(cell.row) + ) { + var parentIndex = view.getParentIndex(view.selection.currentIndex); + while (parentIndex >= 0 && parentIndex != cell.row) { + parentIndex = view.getParentIndex(parentIndex); + } + if (parentIndex == cell.row) { + var parentSelectable = true; + if (parentSelectable) { + view.selection.select(parentIndex); + } + } + } + this.parentNode.changeOpenState(cell.row); + return; + } + + if (!view.selection.single) { + var augment = event.getModifierState("Accel"); + if (event.shiftKey) { + view.selection.rangedSelect(-1, cell.row, augment); + b.ensureRowIsVisible(cell.row); + return; + } + if (augment) { + view.selection.toggleSelect(cell.row); + b.ensureRowIsVisible(cell.row); + view.selection.currentIndex = cell.row; + return; + } + } + + /* We want to deselect all the selected items except what was + clicked, UNLESS it was a right-click. We have to do this + in click rather than mousedown so that you can drag a + selected group of items */ + + if (!cell.col) { + return; + } + + // if the last row has changed in between the time we + // mousedown and the time we click, don't fire the select handler. + // see bug #92366 + if ( + !cell.col.cycler && + this._lastSelectedRow == cell.row && + cell.col.type != window.TreeColumn.TYPE_CHECKBOX + ) { + view.selection.select(cell.row); + b.ensureRowIsVisible(cell.row); + } + }); + + /** + * double-click + */ + this.addEventListener("dblclick", event => { + if (this.parentNode.disabled) { + return; + } + var tree = this.parentNode; + var view = this.parentNode.view; + var row = view.selection.currentIndex; + + if (row == -1) { + return; + } + + var cell = tree.getCellAt(event.clientX, event.clientY); + + if (cell.childElt != "twisty") { + this.parentNode.startEditing(row, cell.col); + } + + if (this.parentNode._editingColumn || !view.isContainer(row)) { + return; + } + + // Cyclers and twisties respond to single clicks, not double clicks + if (cell.col && !cell.col.cycler && cell.childElt != "twisty") { + this.parentNode.changeOpenState(row); + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("slot", "treechildren"); + + this._lastSelectedRow = -1; + + if ("_ensureColumnOrder" in this.parentNode) { + this.parentNode._ensureColumnOrder(); + } + } + } + + customElements.define("treechildren", MozTreeChildren); + + class MozTreecolPicker extends MozElements.BaseControl { + static get entities() { + return ["chrome://global/locale/tree.dtd"]; + } + + static get markup() { + return ` + <image class="tree-columnpicker-icon"></image> + <menupopup anonid="popup"> + <menuseparator anonid="menuseparator"></menuseparator> + <menuitem anonid="menuitem" label="&restoreColumnOrder.label;"></menuitem> + </menupopup> + `; + } + constructor() { + super(); + + this.addEventListener("command", event => { + if (event.originalTarget == this) { + var popup = this.querySelector('[anonid="popup"]'); + this.buildPopup(popup); + popup.openPopup(this, "after_end"); + } else { + var tree = this.parentNode.parentNode; + tree.stopEditing(true); + var menuitem = this.querySelector('[anonid="menuitem"]'); + if (event.originalTarget == menuitem) { + this.style.MozBoxOrdinalGroup = ""; + tree._ensureColumnOrder(tree.NATURAL_ORDER); + } else { + var colindex = event.originalTarget.getAttribute("colindex"); + var column = tree.columns[colindex]; + if (column) { + var element = column.element; + if (element.getAttribute("hidden") == "true") { + element.setAttribute("hidden", "false"); + } else { + element.setAttribute("hidden", "true"); + } + } + } + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + } + + buildPopup(aPopup) { + // We no longer cache the picker content, remove the old content related to + // the cols - menuitem and separator should stay. + aPopup.querySelectorAll("[colindex]").forEach(e => { + e.remove(); + }); + + var refChild = aPopup.firstChild; + + var tree = this.parentNode.parentNode; + for ( + var currCol = tree.columns.getFirstColumn(); + currCol; + currCol = currCol.getNext() + ) { + // Construct an entry for each column in the row, unless + // it is not being shown. + var currElement = currCol.element; + if (!currElement.hasAttribute("ignoreincolumnpicker")) { + var popupChild = document.createXULElement("menuitem"); + popupChild.setAttribute("type", "checkbox"); + var columnName = + currElement.getAttribute("display") || + currElement.getAttribute("label"); + popupChild.setAttribute("label", columnName); + popupChild.setAttribute("colindex", currCol.index); + if (currElement.getAttribute("hidden") != "true") { + popupChild.setAttribute("checked", "true"); + } + if (currCol.primary) { + popupChild.setAttribute("disabled", "true"); + } + if (currElement.hasAttribute("closemenu")) { + popupChild.setAttribute( + "closemenu", + currElement.getAttribute("closemenu") + ); + } + aPopup.insertBefore(popupChild, refChild); + } + } + + var hidden = !tree.enableColumnDrag; + aPopup.querySelectorAll(":scope > :not([colindex])").forEach(e => { + e.hidden = hidden; + }); + } + } + + customElements.define("treecolpicker", MozTreecolPicker); + + class MozTreecol extends MozElements.BaseControl { + static get inheritedAttributes() { + return { + ".treecol-sortdirection": "sortdirection,hidden=hideheader", + ".treecol-text": "value=label,crop", + }; + } + + static get markup() { + return ` + <label class="treecol-text" flex="1" crop="right"></label> + <image class="treecol-sortdirection"></image> + `; + } + + constructor() { + super(); + + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + if (this.parentNode.parentNode.enableColumnDrag) { + var XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + var cols = this.parentNode.getElementsByTagNameNS(XUL_NS, "treecol"); + + // only start column drag operation if there are at least 2 visible columns + var visible = 0; + for (var i = 0; i < cols.length; ++i) { + if (cols[i].getBoundingClientRect().width > 0) { + ++visible; + } + } + + if (visible > 1) { + window.addEventListener("mousemove", this._onDragMouseMove, true); + window.addEventListener("mouseup", this._onDragMouseUp, true); + document.treecolDragging = this; + this.mDragGesturing = true; + this.mStartDragX = event.clientX; + this.mStartDragY = event.clientY; + } + } + }); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + if (event.target != event.originalTarget) { + return; + } + + // On Windows multiple clicking on tree columns only cycles one time + // every 2 clicks. + if (AppConstants.platform == "win" && event.detail % 2 == 0) { + return; + } + + var tree = this.parentNode.parentNode; + if (tree.columns) { + tree.view.cycleHeader(tree.columns.getColumnFor(this)); + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + if (this.hasAttribute("ordinal")) { + this.style.MozBoxOrdinalGroup = this.getAttribute("ordinal"); + } + } + + set ordinal(val) { + this.style.MozBoxOrdinalGroup = val; + this.setAttribute("ordinal", val); + return val; + } + + get ordinal() { + var val = this.style.MozBoxOrdinalGroup; + if (val == "") { + return "1"; + } + + return "" + (val == "0" ? 0 : parseInt(val)); + } + + get _previousVisibleColumn() { + var tree = this.parentNode.parentNode; + let sib = tree.columns.getColumnFor(this).previousColumn; + while (sib) { + if (sib.element && sib.element.getBoundingClientRect().width > 0) { + return sib.element; + } + + sib = sib.previousColumn; + } + + return null; + } + + _onDragMouseMove(aEvent) { + var col = document.treecolDragging; + if (!col) { + return; + } + + // determine if we have moved the mouse far enough + // to initiate a drag + if (col.mDragGesturing) { + if ( + Math.abs(aEvent.clientX - col.mStartDragX) < 5 && + Math.abs(aEvent.clientY - col.mStartDragY) < 5 + ) { + return; + } + col.mDragGesturing = false; + col.setAttribute("dragging", "true"); + window.addEventListener("click", col._onDragMouseClick, true); + } + + var pos = {}; + var targetCol = col.parentNode.parentNode._getColumnAtX( + aEvent.clientX, + 0.5, + pos + ); + + // bail if we haven't mousemoved to a different column + if (col.mTargetCol == targetCol && col.mTargetDir == pos.value) { + return; + } + + var tree = col.parentNode.parentNode; + var sib; + var column; + if (col.mTargetCol) { + // remove previous insertbefore/after attributes + col.mTargetCol.removeAttribute("insertbefore"); + col.mTargetCol.removeAttribute("insertafter"); + column = tree.columns.getColumnFor(col.mTargetCol); + tree.invalidateColumn(column); + sib = col.mTargetCol._previousVisibleColumn; + if (sib) { + sib.removeAttribute("insertafter"); + column = tree.columns.getColumnFor(sib); + tree.invalidateColumn(column); + } + col.mTargetCol = null; + col.mTargetDir = null; + } + + if (targetCol) { + // set insertbefore/after attributes + if (pos.value == "after") { + targetCol.setAttribute("insertafter", "true"); + } else { + targetCol.setAttribute("insertbefore", "true"); + sib = targetCol._previousVisibleColumn; + if (sib) { + sib.setAttribute("insertafter", "true"); + column = tree.columns.getColumnFor(sib); + tree.invalidateColumn(column); + } + } + column = tree.columns.getColumnFor(targetCol); + tree.invalidateColumn(column); + col.mTargetCol = targetCol; + col.mTargetDir = pos.value; + } + } + + _onDragMouseUp(aEvent) { + var col = document.treecolDragging; + if (!col) { + return; + } + + if (!col.mDragGesturing) { + if (col.mTargetCol) { + // remove insertbefore/after attributes + var before = col.mTargetCol.hasAttribute("insertbefore"); + col.mTargetCol.removeAttribute( + before ? "insertbefore" : "insertafter" + ); + + var sib = col.mTargetCol._previousVisibleColumn; + if (before && sib) { + sib.removeAttribute("insertafter"); + } + + // Move the column only if it will result in a different column + // ordering + var move = true; + + // If this is a before move and the previous visible column is + // the same as the column we're moving, don't move + if (before && col == sib) { + move = false; + } else if (!before && col == col.mTargetCol) { + // If this is an after move and the column we're moving is + // the same as the target column, don't move. + move = false; + } + + if (move) { + col.parentNode.parentNode._reorderColumn( + col, + col.mTargetCol, + before + ); + } + + // repaint to remove lines + col.parentNode.parentNode.invalidate(); + + col.mTargetCol = null; + } + } else { + col.mDragGesturing = false; + } + + document.treecolDragging = null; + col.removeAttribute("dragging"); + + window.removeEventListener("mousemove", col._onDragMouseMove, true); + window.removeEventListener("mouseup", col._onDragMouseUp, true); + // we have to wait for the click event to fire before removing + // cancelling handler + var clickHandler = function(handler) { + window.removeEventListener("click", handler, true); + }; + window.setTimeout(clickHandler, 0, col._onDragMouseClick); + } + + _onDragMouseClick(aEvent) { + // prevent click event from firing after column drag and drop + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + } + + customElements.define("treecol", MozTreecol); + + class MozTreecols extends MozElements.BaseControl { + static get inheritedAttributes() { + return { + treecolpicker: "tooltiptext=pickertooltiptext", + }; + } + + static get markup() { + return ` + <treecolpicker class="treecol-image" fixed="true"></treecolpicker> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("slot", "treecols"); + + if (!this.querySelector("treecolpicker")) { + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + } + + // Set resizeafter="farthest" on the splitters if nothing else has been + // specified. + for (let splitter of this.getElementsByTagName("splitter")) { + if (!splitter.hasAttribute("resizeafter")) { + splitter.setAttribute("resizeafter", "farthest"); + } + } + } + } + + customElements.define("treecols", MozTreecols); + + class MozTree extends MozElements.BaseControlMixin( + MozElements.MozElementMixin(XULTreeElement) + ) { + static get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/content/widgets.css" /> + <html:slot name="treecols"></html:slot> + <stack class="tree-stack" flex="1"> + <hbox class="tree-rows" flex="1"> + <hbox flex="1" class="tree-bodybox"> + <html:slot name="treechildren"></html:slot> + </hbox> + <scrollbar height="0" minwidth="0" minheight="0" orient="vertical" + class="hidevscroll-scrollbar scrollbar-topmost" + ></scrollbar> + </hbox> + <html:input class="tree-input" type="text" hidden="true"/> + </stack> + <hbox class="hidehscroll-box"> + <scrollbar orient="horizontal" flex="1" increment="16" class="scrollbar-topmost" ></scrollbar> + <scrollcorner class="hidevscroll-scrollcorner"></scrollcorner> + </hbox> + `; + } + + constructor() { + super(); + + // These enumerated constants are used as the first argument to + // _ensureColumnOrder to specify what column ordering should be used. + this.CURRENT_ORDER = 0; + this.NATURAL_ORDER = 1; // The original order, which is the DOM ordering + + this.attachShadow({ mode: "open" }); + let handledElements = this.constructor.fragment.querySelectorAll( + "scrollbar,scrollcorner" + ); + let stopAndPrevent = e => { + e.stopPropagation(); + e.preventDefault(); + }; + let stopProp = e => e.stopPropagation(); + for (let el of handledElements) { + el.addEventListener("click", stopAndPrevent); + el.addEventListener("contextmenu", stopAndPrevent); + el.addEventListener("dblclick", stopProp); + el.addEventListener("command", stopProp); + } + this.shadowRoot.appendChild(this.constructor.fragment); + } + + static get inheritedAttributes() { + return { + ".hidehscroll-box": "collapsed=hidehscroll", + ".hidevscroll-scrollbar": "collapsed=hidevscroll", + ".hidevscroll-scrollcorner": "collapsed=hidevscroll", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + if (!this._eventListenersSetup) { + this._eventListenersSetup = true; + this.setupEventListeners(); + } + + this.setAttribute("hidevscroll", "true"); + this.setAttribute("hidehscroll", "true"); + this.setAttribute("clickthrough", "never"); + + this.initializeAttributeInheritance(); + + this.pageUpOrDownMovesSelection = AppConstants.platform != "macosx"; + + this._inputField = null; + + this._editingRow = -1; + + this._editingColumn = null; + + this._columnsDirty = true; + + this._lastKeyTime = 0; + + this._incrementalString = ""; + + this._touchY = -1; + } + + setupEventListeners() { + this.addEventListener("underflow", event => { + // Scrollport event orientation + // 0: vertical + // 1: horizontal + // 2: both (not used) + if (event.target.tagName != "treechildren") { + return; + } + if (event.detail == 1) { + this.setAttribute("hidehscroll", "true"); + } else if (event.detail == 0) { + this.setAttribute("hidevscroll", "true"); + } + event.stopPropagation(); + }); + + this.addEventListener("overflow", event => { + if (event.target.tagName != "treechildren") { + return; + } + if (event.detail == 1) { + this.removeAttribute("hidehscroll"); + } else if (event.detail == 0) { + this.removeAttribute("hidevscroll"); + } + event.stopPropagation(); + }); + + this.addEventListener("touchstart", event => { + function isScrollbarElement(target) { + return ( + (target.localName == "thumb" || target.localName == "slider") && + target.namespaceURI == + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + ); + } + if ( + event.touches.length > 1 || + isScrollbarElement(event.touches[0].target) + ) { + // Multiple touch points detected, abort. In particular this aborts + // the panning gesture when the user puts a second finger down after + // already panning with one finger. Aborting at this point prevents + // the pan gesture from being resumed until all fingers are lifted + // (as opposed to when the user is back down to one finger). + // Additionally, if the user lands on the scrollbar don't use this + // code for scrolling, instead allow gecko to handle scrollbar + // interaction normally. + this._touchY = -1; + } else { + this._touchY = event.touches[0].screenY; + } + }); + + this.addEventListener("touchmove", event => { + if (event.touches.length == 1 && this._touchY >= 0) { + var deltaY = this._touchY - event.touches[0].screenY; + var lines = Math.trunc(deltaY / this.rowHeight); + if (Math.abs(lines) > 0) { + this.scrollByLines(lines); + deltaY -= lines * this.rowHeight; + this._touchY = event.touches[0].screenY + deltaY; + } + event.preventDefault(); + } + }); + + this.addEventListener("touchend", event => { + this._touchY = -1; + }); + + // This event doesn't retarget, so listen on the shadow DOM directly + this.shadowRoot.addEventListener("MozMousePixelScroll", event => { + if ( + !( + this.getAttribute("allowunderflowscroll") == "true" && + this.getAttribute("hidevscroll") == "true" + ) + ) { + event.preventDefault(); + } + }); + + // This event doesn't retarget, so listen on the shadow DOM directly + this.shadowRoot.addEventListener("DOMMouseScroll", event => { + if ( + !( + this.getAttribute("allowunderflowscroll") == "true" && + this.getAttribute("hidevscroll") == "true" + ) + ) { + event.preventDefault(); + } + + if (this._editingColumn) { + return; + } + if (event.axis == event.HORIZONTAL_AXIS) { + return; + } + + var rows = event.detail; + if (rows == UIEvent.SCROLL_PAGE_UP) { + this.scrollByPages(-1); + } else if (rows == UIEvent.SCROLL_PAGE_DOWN) { + this.scrollByPages(1); + } else { + this.scrollByLines(rows); + } + }); + + this.addEventListener("MozSwipeGesture", event => { + // Figure out which row to show + let targetRow = 0; + + // Only handle swipe gestures up and down + switch (event.direction) { + case event.DIRECTION_DOWN: + targetRow = this.view.rowCount - 1; + // Fall through for actual action + case event.DIRECTION_UP: + this.ensureRowIsVisible(targetRow); + break; + } + }); + + this.addEventListener("select", event => { + if (event.originalTarget == this) { + this.stopEditing(true); + } + }); + + this.addEventListener("focus", event => { + this.focused = true; + if (this.currentIndex == -1 && this.view.rowCount > 0) { + this.currentIndex = this.getFirstVisibleRow(); + } + }); + + this.addEventListener( + "blur", + event => { + this.focused = false; + if (event.target == this.inputField) { + this.stopEditing(true); + } + }, + true + ); + + this.addEventListener("keydown", event => { + if (event.altKey) { + return; + } + + let toggleClose = () => { + if (this._editingColumn) { + return; + } + + let row = this.currentIndex; + if (row < 0) { + return; + } + + if (this.changeOpenState(this.currentIndex, false)) { + event.preventDefault(); + return; + } + + let parentIndex = this.view.getParentIndex(this.currentIndex); + if (parentIndex >= 0) { + this.view.selection.select(parentIndex); + this.ensureRowIsVisible(parentIndex); + event.preventDefault(); + } + }; + + let toggleOpen = () => { + if (this._editingColumn) { + return; + } + + let row = this.currentIndex; + if (row < 0) { + return; + } + + if (this.changeOpenState(row, true)) { + event.preventDefault(); + return; + } + let c = row + 1; + let view = this.view; + if (c < view.rowCount && view.getParentIndex(c) == row) { + // If already opened, select the first child. + // The getParentIndex test above ensures that the children + // are already populated and ready. + this.view.selection.timedSelect(c, this._selectDelay); + this.ensureRowIsVisible(c); + event.preventDefault(); + } + }; + + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: { + if (this._handleEnter(event)) { + event.stopPropagation(); + event.preventDefault(); + } + break; + } + case KeyEvent.DOM_VK_ESCAPE: { + if (this._editingColumn) { + this.stopEditing(false); + this.focus(); + event.stopPropagation(); + event.preventDefault(); + } + break; + } + case KeyEvent.DOM_VK_LEFT: { + if (!this.isRTL) { + toggleClose(); + } else { + toggleOpen(); + } + break; + } + case KeyEvent.DOM_VK_RIGHT: { + if (!this.isRTL) { + toggleOpen(); + } else { + toggleClose(); + } + break; + } + case KeyEvent.DOM_VK_UP: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByOffsetShift(-1, 0, event); + } else { + this._moveByOffset(-1, 0, event); + } + break; + } + case KeyEvent.DOM_VK_DOWN: { + if (this._editingColumn) { + return; + } + if (event.getModifierState("Shift")) { + this._moveByOffsetShift(1, this.view.rowCount - 1, event); + } else { + this._moveByOffset(1, this.view.rowCount - 1, event); + } + break; + } + case KeyEvent.DOM_VK_PAGE_UP: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByPageShift(-1, 0, event); + } else { + this._moveByPage(-1, 0, event); + } + break; + } + case KeyEvent.DOM_VK_PAGE_DOWN: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByPageShift(1, this.view.rowCount - 1, event); + } else { + this._moveByPage(1, this.view.rowCount - 1, event); + } + break; + } + case KeyEvent.DOM_VK_HOME: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveToEdgeShift(0, event); + } else { + this._moveToEdge(0, event); + } + break; + } + case KeyEvent.DOM_VK_END: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveToEdgeShift(this.view.rowCount - 1, event); + } else { + this._moveToEdge(this.view.rowCount - 1, event); + } + break; + } + } + }); + + this.addEventListener("keypress", event => { + if (this._editingColumn) { + return; + } + + if (event.charCode == " ".charCodeAt(0)) { + var c = this.currentIndex; + if ( + !this.view.selection.isSelected(c) || + (!this.view.selection.single && event.getModifierState("Accel")) + ) { + this.view.selection.toggleSelect(c); + event.preventDefault(); + } + } else if ( + !this.disableKeyNavigation && + event.charCode > 0 && + !event.altKey && + !event.getModifierState("Accel") && + !event.metaKey && + !event.ctrlKey + ) { + var l = this._keyNavigate(event); + if (l >= 0) { + this.view.selection.timedSelect(l, this._selectDelay); + this.ensureRowIsVisible(l); + } + event.preventDefault(); + } + }); + } + + get body() { + return this.treeBody; + } + + get isRTL() { + return document.defaultView.getComputedStyle(this).direction == "rtl"; + } + + set editable(val) { + if (val) { + this.setAttribute("editable", "true"); + } else { + this.removeAttribute("editable"); + } + return val; + } + + get editable() { + return this.getAttribute("editable") == "true"; + } + /** + * ///////////////// nsIDOMXULSelectControlElement ///////////////// ///////////////// nsIDOMXULMultiSelectControlElement ///////////////// + */ + set selType(val) { + this.setAttribute("seltype", val); + return val; + } + + get selType() { + return this.getAttribute("seltype"); + } + + set currentIndex(val) { + if (this.view) { + return (this.view.selection.currentIndex = val); + } + return val; + } + + get currentIndex() { + if (this.view && this.view.selection) { + return this.view.selection.currentIndex; + } + return -1; + } + + set keepCurrentInView(val) { + if (val) { + this.setAttribute("keepcurrentinview", "true"); + } else { + this.removeAttribute("keepcurrentinview"); + } + return val; + } + + get keepCurrentInView() { + return this.getAttribute("keepcurrentinview") == "true"; + } + + set enableColumnDrag(val) { + if (val) { + this.setAttribute("enableColumnDrag", "true"); + } else { + this.removeAttribute("enableColumnDrag"); + } + return val; + } + + get enableColumnDrag() { + return this.hasAttribute("enableColumnDrag"); + } + + get inputField() { + if (!this._inputField) { + this._inputField = this.shadowRoot.querySelector(".tree-input"); + this._inputField.addEventListener("blur", () => this.stopEditing(true)); + } + return this._inputField; + } + + set disableKeyNavigation(val) { + if (val) { + this.setAttribute("disableKeyNavigation", "true"); + } else { + this.removeAttribute("disableKeyNavigation"); + } + return val; + } + + get disableKeyNavigation() { + return this.hasAttribute("disableKeyNavigation"); + } + + get editingRow() { + return this._editingRow; + } + + get editingColumn() { + return this._editingColumn; + } + + set _selectDelay(val) { + this.setAttribute("_selectDelay", val); + } + + get _selectDelay() { + return this.getAttribute("_selectDelay") || 50; + } + + // The first argument (order) can be either one of these constants: + // this.CURRENT_ORDER + // this.NATURAL_ORDER + _ensureColumnOrder(order = this.CURRENT_ORDER) { + if (this.columns) { + // update the ordinal position of each column to assure that it is + // an odd number and 2 positions above its next sibling + var cols = []; + + if (order == this.CURRENT_ORDER) { + for ( + let col = this.columns.getFirstColumn(); + col; + col = col.getNext() + ) { + cols.push(col.element); + } + } else { + // order == this.NATURAL_ORDER + cols = this.getElementsByTagName("treecol"); + } + + for (let i = 0; i < cols.length; ++i) { + cols[i].ordinal = i * 2 + 1; + } + // update the ordinal positions of splitters to even numbers, so that + // they are in between columns + var splitters = this.getElementsByTagName("splitter"); + for (let i = 0; i < splitters.length; ++i) { + splitters[i].style.MozBoxOrdinalGroup = (i + 1) * 2; + } + } + } + + _reorderColumn(aColMove, aColBefore, aBefore) { + this._ensureColumnOrder(); + + var i; + var cols = []; + var col = this.columns.getColumnFor(aColBefore); + if (parseInt(aColBefore.ordinal) < parseInt(aColMove.ordinal)) { + if (aBefore) { + cols.push(aColBefore); + } + for ( + col = col.getNext(); + col.element != aColMove; + col = col.getNext() + ) { + cols.push(col.element); + } + + aColMove.ordinal = cols[0].ordinal; + for (i = 0; i < cols.length; ++i) { + cols[i].ordinal = parseInt(cols[i].ordinal) + 2; + } + } else if (aColBefore.ordinal != aColMove.ordinal) { + if (!aBefore) { + cols.push(aColBefore); + } + for ( + col = col.getPrevious(); + col.element != aColMove; + col = col.getPrevious() + ) { + cols.push(col.element); + } + + aColMove.ordinal = cols[0].ordinal; + for (i = 0; i < cols.length; ++i) { + cols[i].ordinal = parseInt(cols[i].ordinal) - 2; + } + } + } + + _getColumnAtX(aX, aThresh, aPos) { + let isRTL = this.isRTL; + + if (aPos) { + aPos.value = isRTL ? "after" : "before"; + } + + var columns = []; + var col = this.columns.getFirstColumn(); + while (col) { + columns.push(col); + col = col.getNext(); + } + if (isRTL) { + columns.reverse(); + } + var currentX = this.getBoundingClientRect().x; + var adjustedX = aX + this.horizontalPosition; + for (var i = 0; i < columns.length; ++i) { + col = columns[i]; + var cw = col.element.getBoundingClientRect().width; + if (cw > 0) { + currentX += cw; + if (currentX - cw * aThresh > adjustedX) { + return col.element; + } + } + } + + if (aPos) { + aPos.value = isRTL ? "before" : "after"; + } + return columns.pop().element; + } + + changeOpenState(row, openState) { + if (row < 0 || !this.view.isContainer(row)) { + return false; + } + + if (this.view.isContainerOpen(row) != openState) { + this.view.toggleOpenState(row); + if (row == this.currentIndex) { + // Only fire event when current row is expanded or collapsed + // because that's all the assistive technology really cares about. + var event = document.createEvent("Events"); + event.initEvent("OpenStateChange", true, true); + this.dispatchEvent(event); + } + return true; + } + return false; + } + + _keyNavigate(event) { + var key = String.fromCharCode(event.charCode).toLowerCase(); + if (event.timeStamp - this._lastKeyTime > 1000) { + this._incrementalString = key; + } else { + this._incrementalString += key; + } + this._lastKeyTime = event.timeStamp; + + var length = this._incrementalString.length; + var incrementalString = this._incrementalString; + var charIndex = 1; + while ( + charIndex < length && + incrementalString[charIndex] == incrementalString[charIndex - 1] + ) { + charIndex++; + } + // If all letters in incremental string are same, just try to match the first one + if (charIndex == length) { + length = 1; + incrementalString = incrementalString.substring(0, length); + } + + var keyCol = this.columns.getKeyColumn(); + var rowCount = this.view.rowCount; + var start = 1; + + var c = this.currentIndex; + if (length > 1) { + start = 0; + if (c < 0) { + c = 0; + } + } + + for (var i = 0; i < rowCount; i++) { + var l = (i + start + c) % rowCount; + var cellText = this.view.getCellText(l, keyCol); + cellText = cellText.substring(0, length).toLowerCase(); + if (cellText == incrementalString) { + return l; + } + } + return -1; + } + + startEditing(row, column) { + if (!this.editable) { + return false; + } + if (row < 0 || row >= this.view.rowCount || !column) { + return false; + } + if (column.type !== window.TreeColumn.TYPE_TEXT) { + return false; + } + if (column.cycler || !this.view.isEditable(row, column)) { + return false; + } + + // Beyond this point, we are going to edit the cell. + if (this._editingColumn) { + this.stopEditing(); + } + + var input = this.inputField; + + this.ensureCellIsVisible(row, column); + + // Get the coordinates of the text inside the cell. + var textRect = this.getCoordsForCellItem(row, column, "text"); + + // Get the coordinates of the cell itself. + var cellRect = this.getCoordsForCellItem(row, column, "cell"); + + // Calculate the top offset of the textbox. + var style = window.getComputedStyle(input); + var topadj = parseInt(style.borderTopWidth) + parseInt(style.paddingTop); + input.style.top = `${textRect.y - topadj}px`; + + // The leftside of the textbox is aligned to the left side of the text + // in LTR mode, and left side of the cell in RTL mode. + var left, widthdiff; + if (style.direction == "rtl") { + left = cellRect.x; + widthdiff = cellRect.x - textRect.x; + } else { + left = textRect.x; + widthdiff = textRect.x - cellRect.x; + } + + input.style.left = `${left}px`; + input.style.height = `${textRect.height + + topadj + + parseInt(style.borderBottomWidth) + + parseInt(style.paddingBottom)}px`; + input.style.width = `${cellRect.width - widthdiff}px`; + input.hidden = false; + + input.value = this.view.getCellText(row, column); + + input.select(); + input.focus(); + + this._editingRow = row; + this._editingColumn = column; + this.setAttribute("editing", "true"); + + this.invalidateCell(row, column); + return true; + } + + stopEditing(accept) { + if (!this._editingColumn) { + return; + } + + var input = this.inputField; + var editingRow = this._editingRow; + var editingColumn = this._editingColumn; + this._editingRow = -1; + this._editingColumn = null; + + // `this.view` could be null if the tree was hidden before we were called. + if (accept && this.view) { + var value = input.value; + this.view.setCellText(editingRow, editingColumn, value); + } + input.hidden = true; + input.value = ""; + this.removeAttribute("editing"); + } + + _moveByOffset(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (event.getModifierState("Accel") && this.view.selection.single) { + this.scrollByLines(offset); + return; + } + + var c = this.currentIndex + offset; + if (offset > 0 ? c > edge : c < edge) { + if ( + this.view.selection.isSelected(edge) && + this.view.selection.count <= 1 + ) { + return; + } + c = edge; + } + + if (!event.getModifierState("Accel")) { + this.view.selection.timedSelect(c, this._selectDelay); + } + // Ctrl+Up/Down moves the anchor without selecting + else { + this.currentIndex = c; + } + this.ensureRowIsVisible(c); + } + + _moveByOffsetShift(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.view.selection.single) { + this.scrollByLines(offset); + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + var c = this.currentIndex; + if (c == -1) { + c = 0; + } + + if (c == edge) { + if (this.view.selection.isSelected(c)) { + return; + } + } + + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + c + offset, + event.getModifierState("Accel") + ); + this.ensureRowIsVisible(c + offset); + } + + _moveByPage(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.pageUpOrDownMovesSelection == event.getModifierState("Accel")) { + this.scrollByPages(offset); + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + var c = this.currentIndex; + if (c == -1) { + return; + } + + if (c == edge && this.view.selection.isSelected(c)) { + this.ensureRowIsVisible(c); + return; + } + var i = this.getFirstVisibleRow(); + var p = this.getPageLength(); + + if (offset > 0) { + i += p - 1; + if (c >= i) { + i = c + p; + this.ensureRowIsVisible(i > edge ? edge : i); + } + i = i > edge ? edge : i; + } else if (c <= i) { + i = c <= p ? 0 : c - p; + this.ensureRowIsVisible(i); + } + this.view.selection.timedSelect(i, this._selectDelay); + } + + _moveByPageShift(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if ( + this.view.rowCount == 1 && + !this.view.selection.isSelected(0) && + !(this.pageUpOrDownMovesSelection == event.getModifierState("Accel")) + ) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + if (this.view.selection.single) { + return; + } + + var c = this.currentIndex; + if (c == -1) { + return; + } + if (c == edge && this.view.selection.isSelected(c)) { + this.ensureRowIsVisible(edge); + return; + } + var i = this.getFirstVisibleRow(); + var p = this.getPageLength(); + + if (offset > 0) { + i += p - 1; + if (c >= i) { + i = c + p; + this.ensureRowIsVisible(i > edge ? edge : i); + } + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + i > edge ? edge : i, + event.getModifierState("Accel") + ); + } else { + if (c <= i) { + i = c <= p ? 0 : c - p; + this.ensureRowIsVisible(i); + } + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + i, + event.getModifierState("Accel") + ); + } + } + + _moveToEdge(edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if ( + this.view.selection.isSelected(edge) && + this.view.selection.count == 1 + ) { + this.currentIndex = edge; + return; + } + + // Normal behaviour is to select the first/last row + if (!event.getModifierState("Accel")) { + this.view.selection.timedSelect(edge, this._selectDelay); + } + // In a multiselect tree Ctrl+Home/End moves the anchor + else if (!this.view.selection.single) { + this.currentIndex = edge; + } + + this.ensureRowIsVisible(edge); + } + + _moveToEdgeShift(edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + if ( + this.view.selection.single || + (this.view.selection.isSelected(edge) && + this.view.selection.isSelected(this.currentIndex)) + ) { + return; + } + + // Extend the selection from the existing pivot, if any. + // -1 doesn't work here, so using currentIndex instead + this.view.selection.rangedSelect( + this.currentIndex, + edge, + event.getModifierState("Accel") + ); + + this.ensureRowIsVisible(edge); + } + + _handleEnter(event) { + if (this._editingColumn) { + this.stopEditing(true); + this.focus(); + return true; + } + + return this.changeOpenState(this.currentIndex); + } + } + + MozXULElement.implementCustomInterface(MozTree, [ + Ci.nsIDOMXULMultiSelectControlElement, + ]); + customElements.define("tree", MozTree); +} diff --git a/toolkit/content/widgets/videocontrols.js b/toolkit/content/widgets/videocontrols.js new file mode 100644 index 0000000000..c34f4d082a --- /dev/null +++ b/toolkit/content/widgets/videocontrols.js @@ -0,0 +1,3339 @@ +/* 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 is a UA widget. It runs in per-origin UA widget scope, +// to be loaded by UAWidgetsChild.jsm. + +/* + * This is the class of entry. It will construct the actual implementation + * according to the value of the "controls" property. + */ +this.VideoControlsWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + + this.isMobile = this.window.navigator.appVersion.includes("Android"); + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgets when the "controls" property changes. + */ + onchange() { + this.switchImpl(); + } + + /* + * Actually switch the implementation. + * - With "controls" set, the VideoControlsImplWidget controls should load. + * - Without it, on mobile, the NoControlsMobileImplWidget should load, so + * the user could see the click-to-play button when the video/audio is blocked. + * - Without it, on desktop, the NoControlsPictureInPictureImpleWidget should load + * if the video is being viewed in Picture-in-Picture. + */ + switchImpl() { + let newImpl; + let pageURI = this.document.documentURI; + if (this.element.controls) { + newImpl = VideoControlsImplWidget; + } else if (this.isMobile) { + newImpl = NoControlsMobileImplWidget; + } else if (VideoControlsWidget.isPictureInPictureVideo(this.element)) { + newImpl = NoControlsPictureInPictureImplWidget; + } else if ( + pageURI.startsWith("http://") || + pageURI.startsWith("https://") + ) { + newImpl = NoControlsDesktopImplWidget; + } + + // Skip if we are asked to load the same implementation, and + // the underlying element state hasn't changed in ways that we + // care about. This can happen if the property is set again + // without a value change. + if ( + this.impl && + this.impl.constructor == newImpl && + this.impl.elementStateMatches(this.element) + ) { + return; + } + if (this.impl) { + this.impl.destructor(); + this.shadowRoot.firstChild.remove(); + } + if (newImpl) { + this.impl = new newImpl(this.shadowRoot, this.prefs); + + this.mDirection = "ltr"; + let intlUtils = this.window.intlUtils; + if (intlUtils) { + this.mDirection = intlUtils.isAppLocaleRTL() ? "rtl" : "ltr"; + } + + this.impl.onsetup(this.mDirection); + } else { + this.impl = undefined; + } + } + + destructor() { + if (!this.impl) { + return; + } + this.impl.destructor(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + + if (!this.impl) { + return; + } + + this.impl.onPrefChange(prefName, prefValue); + } + + static isPictureInPictureVideo(someVideo) { + return someVideo.isCloningElementVisually; + } + + /** + * Returns true if a <video> meets the requirements to show the Picture-in-Picture + * toggle. Those requirements currently are: + * + * 1. The video must be 45 seconds in length or longer. + * 2. Neither the width or the height of the video can be less than 140px. + * 3. The video must have audio. + * 4. The video must not a MediaStream video (Bug 1592539) + * + * This can be overridden via the + * media.videocontrols.picture-in-picture.video-toggle.always-show pref, which + * is mostly used for testing. + * + * @param {Object} prefs + * The preferences set that was passed to the UAWidget. + * @param {Element} someVideo + * The <video> to test. + * @param {Object} reflowedDimensions + * An object representing the reflowed dimensions of the <video>. Properties + * are: + * + * videoWidth (Number): + * The width of the video in pixels. + * + * videoHeight (Number): + * The height of the video in pixels. + * + * @return {Boolean} + */ + static shouldShowPictureInPictureToggle( + prefs, + someVideo, + reflowedDimensions + ) { + if ( + prefs["media.videocontrols.picture-in-picture.video-toggle.always-show"] + ) { + return true; + } + + const MIN_VIDEO_LENGTH = + prefs[ + "media.videocontrols.picture-in-picture.video-toggle.min-video-secs" + ]; + + if (someVideo.duration < MIN_VIDEO_LENGTH) { + return false; + } + + const MIN_VIDEO_DIMENSION = 140; // pixels + if ( + reflowedDimensions.videoWidth < MIN_VIDEO_DIMENSION || + reflowedDimensions.videoHeight < MIN_VIDEO_DIMENSION + ) { + return false; + } + + if (!someVideo.mozHasAudio) { + return false; + } + + return true; + } + + /** + * Some variations on the Picture-in-Picture toggle are being experimented with. + * These variations have slightly different setup parameters from the currently + * shipping toggle, so this method sets up the experimental toggles in the event + * that they're being used. It also will enable the appropriate stylesheet for + * the preferred toggle experiment. + * + * @param {Object} prefs + * The preferences set that was passed to the UAWidget. + * @param {ShadowRoot} shadowRoot + * The shadowRoot of the <video> element where the video controls are. + * @param {Element} toggle + * The toggle element. + * @param {Object} reflowedDimensions + * An object representing the reflowed dimensions of the <video>. Properties + * are: + * + * videoWidth (Number): + * The width of the video in pixels. + * + * videoHeight (Number): + * The height of the video in pixels. + */ + static setupToggle(prefs, toggle, reflowedDimensions) { + // These thresholds are all in pixels + const SMALL_VIDEO_WIDTH_MAX = 320; + const MEDIUM_VIDEO_WIDTH_MAX = 720; + + let isSmall = reflowedDimensions.videoWidth <= SMALL_VIDEO_WIDTH_MAX; + toggle.toggleAttribute("small-video", isSmall); + toggle.toggleAttribute( + "medium-video", + !isSmall && reflowedDimensions.videoWidth <= MEDIUM_VIDEO_WIDTH_MAX + ); + + toggle.setAttribute( + "position", + prefs["media.videocontrols.picture-in-picture.video-toggle.position"] + ); + toggle.toggleAttribute( + "has-used", + prefs["media.videocontrols.picture-in-picture.video-toggle.has-used"] + ); + } +}; + +this.VideoControlsImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + debug: false, + video: null, + videocontrols: null, + controlBar: null, + playButton: null, + muteButton: null, + volumeControl: null, + durationLabel: null, + positionLabel: null, + scrubber: null, + progressBar: null, + bufferBar: null, + statusOverlay: null, + controlsSpacer: null, + clickToPlay: null, + controlsOverlay: null, + fullscreenButton: null, + layoutControls: null, + isShowingPictureInPictureMessage: false, + + textTracksCount: 0, + videoEvents: [ + "play", + "pause", + "ended", + "volumechange", + "loadeddata", + "loadstart", + "timeupdate", + "progress", + "playing", + "waiting", + "canplay", + "canplaythrough", + "seeking", + "seeked", + "emptied", + "loadedmetadata", + "error", + "suspend", + "stalled", + "mozvideoonlyseekbegin", + "mozvideoonlyseekcompleted", + "durationchange", + ], + + showHours: false, + firstFrameShown: false, + timeUpdateCount: 0, + maxCurrentTimeSeen: 0, + isPausedByDragging: false, + _isAudioOnly: false, + + get isAudioOnly() { + return this._isAudioOnly; + }, + set isAudioOnly(val) { + this._isAudioOnly = val; + this.setFullscreenButtonState(); + this.updatePictureInPictureToggleDisplay(); + + if (!this.isTopLevelSyntheticDocument) { + return; + } + if (this._isAudioOnly) { + this.video.style.height = this.controlBarMinHeight + "px"; + this.video.style.width = "66%"; + } else { + this.video.style.removeProperty("height"); + this.video.style.removeProperty("width"); + } + }, + + suppressError: false, + + setupStatusFader(immediate) { + // Since the play button will be showing, we don't want to + // show the throbber behind it. The throbber here will + // only show if needed after the play button has been pressed. + if (!this.clickToPlay.hidden) { + this.startFadeOut(this.statusOverlay, true); + return; + } + + var show = false; + if ( + this.video.seeking || + (this.video.error && !this.suppressError) || + this.video.networkState == this.video.NETWORK_NO_SOURCE || + (this.video.networkState == this.video.NETWORK_LOADING && + (this.video.paused || this.video.ended + ? this.video.readyState < this.video.HAVE_CURRENT_DATA + : this.video.readyState < this.video.HAVE_FUTURE_DATA)) || + (this.timeUpdateCount <= 1 && + !this.video.ended && + this.video.readyState < this.video.HAVE_FUTURE_DATA && + this.video.networkState == this.video.NETWORK_LOADING) + ) { + show = true; + } + + // Explicitly hide the status fader if this + // is audio only until bug 619421 is fixed. + if (this.isAudioOnly) { + show = false; + } + + if (this._showThrobberTimer) { + show = true; + } + + this.log( + "Status overlay: seeking=" + + this.video.seeking + + " error=" + + this.video.error + + " readyState=" + + this.video.readyState + + " paused=" + + this.video.paused + + " ended=" + + this.video.ended + + " networkState=" + + this.video.networkState + + " timeUpdateCount=" + + this.timeUpdateCount + + " _showThrobberTimer=" + + this._showThrobberTimer + + " --> " + + (show ? "SHOW" : "HIDE") + ); + this.startFade(this.statusOverlay, show, immediate); + }, + + /* + * Set the initial state of the controls. The UA widget is normally created along + * with video element, but could be attached at any point (eg, if the video is + * removed from the document and then reinserted). Thus, some one-time events may + * have already fired, and so we'll need to explicitly check the initial state. + */ + setupInitialState() { + this.setPlayButtonState(this.video.paused); + + this.setFullscreenButtonState(); + + var duration = Math.round(this.video.duration * 1000); // in ms + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + this.log( + "Initial playback position is at " + currentTime + " of " + duration + ); + // It would be nice to retain maxCurrentTimeSeen, but it would be difficult + // to determine if the media source changed while we were detached. + this.initPositionDurationBox(); + this.maxCurrentTimeSeen = currentTime; + this.showPosition(currentTime, duration); + + // If we have metadata, check if this is a <video> without + // video data, or a video with no audio track. + if (this.video.readyState >= this.video.HAVE_METADATA) { + if ( + this.video.localName == "video" && + (this.video.videoWidth == 0 || this.video.videoHeight == 0) + ) { + this.isAudioOnly = true; + } + + // We have to check again if the media has audio here. + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.disabled = true; + } + } + + // We should lock the orientation if we are already in + // fullscreen. + this.updateOrientationState(this.isVideoInFullScreen); + + // The video itself might not be fullscreen, but part of the + // document might be, in which case we set this attribute to + // apply any styles for the DOM fullscreen case. + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } + + if (this.isAudioOnly) { + this.startFadeOut(this.clickToPlay, true); + } + + // If the first frame hasn't loaded, kick off a throbber fade-in. + if (this.video.readyState >= this.video.HAVE_CURRENT_DATA) { + this.firstFrameShown = true; + } + + // We can't determine the exact buffering status, but do know if it's + // fully loaded. (If it's still loading, it will fire a progress event + // and we'll figure out the exact state then.) + this.bufferBar.max = 100; + if (this.video.readyState >= this.video.HAVE_METADATA) { + this.showBuffered(); + } else { + this.bufferBar.value = 0; + } + + // Set the current status icon. + if (this.hasError()) { + this.startFadeOut(this.clickToPlay, true); + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + } else if (VideoControlsWidget.isPictureInPictureVideo(this.video)) { + this.setShowPictureInPictureMessage(true); + } + + if (this.video.readyState >= this.video.HAVE_METADATA) { + // According to the spec[1], at the HAVE_METADATA (or later) state, we know + // the video duration and dimensions, which means we can calculate whether or + // not to show the Picture-in-Picture toggle now. + // + // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata + this.updatePictureInPictureToggleDisplay(); + } + + let adjustableControls = [ + ...this.prioritizedControls, + this.controlBar, + this.clickToPlay, + ]; + + let throwOnGet = { + get() { + throw new Error("Please don't trigger reflow. See bug 1493525."); + }, + }; + + for (let control of adjustableControls) { + if (!control) { + break; + } + + Object.defineProperties(control, { + // We should directly access CSSOM to get pre-defined style instead of + // retrieving computed dimensions from layout. + minWidth: { + get: () => { + let controlId = control.id; + let propertyName = `--${controlId}-width`; + if (control.modifier) { + propertyName += "-" + control.modifier; + } + let preDefinedSize = this.controlBarComputedStyles.getPropertyValue( + propertyName + ); + + // The stylesheet from <link> might not be loaded if the + // element was inserted into a hidden iframe. + // We can safely return 0 here for now, given that the controls + // will be resized again, by the resizevideocontrols event, + // from nsVideoFrame, when the element is visible. + if (!preDefinedSize) { + return 0; + } + + return parseInt(preDefinedSize, 10); + }, + }, + offsetLeft: throwOnGet, + offsetTop: throwOnGet, + offsetWidth: throwOnGet, + offsetHeight: throwOnGet, + offsetParent: throwOnGet, + clientLeft: throwOnGet, + clientTop: throwOnGet, + clientWidth: throwOnGet, + clientHeight: throwOnGet, + getClientRects: throwOnGet, + getBoundingClientRect: throwOnGet, + isAdjustableControl: { + value: true, + }, + modifier: { + value: "", + writable: true, + }, + isWanted: { + value: true, + writable: true, + }, + hidden: { + set: v => { + control._isHiddenExplicitly = v; + control._updateHiddenAttribute(); + }, + get: () => { + return ( + control.hasAttribute("hidden") || + control.classList.contains("fadeout") + ); + }, + }, + hiddenByAdjustment: { + set: v => { + control._isHiddenByAdjustment = v; + control._updateHiddenAttribute(); + }, + get: () => control._isHiddenByAdjustment, + }, + _isHiddenByAdjustment: { + value: false, + writable: true, + }, + _isHiddenExplicitly: { + value: false, + writable: true, + }, + _updateHiddenAttribute: { + value: () => { + if ( + control._isHiddenExplicitly || + control._isHiddenByAdjustment + ) { + control.setAttribute("hidden", ""); + } else { + control.removeAttribute("hidden"); + } + }, + }, + }); + } + this.adjustControlSize(); + + // Can only update the volume controls once we've computed + // _volumeControlWidth, since the volume slider implementation + // depends on it. + this.updateVolumeControls(); + }, + + updatePictureInPictureToggleDisplay() { + if (this.isAudioOnly) { + this.pictureInPictureToggle.setAttribute("hidden", true); + return; + } + + if ( + this.pipToggleEnabled && + !this.isShowingPictureInPictureMessage && + VideoControlsWidget.shouldShowPictureInPictureToggle( + this.prefs, + this.video, + this.reflowedDimensions + ) + ) { + this.pictureInPictureToggle.removeAttribute("hidden"); + VideoControlsWidget.setupToggle( + this.prefs, + this.pictureInPictureToggle, + this.reflowedDimensions + ); + } else { + this.pictureInPictureToggle.setAttribute("hidden", true); + } + }, + + setupNewLoadState() { + // For videos with |autoplay| set, we'll leave the controls initially hidden, + // so that they don't get in the way of the playing video. Otherwise we'll + // go ahead and reveal the controls now, so they're an obvious user cue. + var shouldShow = + !this.dynamicControls || (this.video.paused && !this.video.autoplay); + // Hide the overlay if the video time is non-zero or if an error occurred to workaround bug 718107. + let shouldClickToPlayShow = + shouldShow && + !this.isAudioOnly && + this.video.currentTime == 0 && + !this.hasError() && + !this.isShowingPictureInPictureMessage; + this.startFade(this.clickToPlay, shouldClickToPlayShow, true); + this.startFade(this.controlBar, shouldShow, true); + }, + + get dynamicControls() { + // Don't fade controls for <audio> elements. + var enabled = !this.isAudioOnly; + + // Allow tests to explicitly suppress the fading of controls. + if (this.video.hasAttribute("mozNoDynamicControls")) { + enabled = false; + } + + // If the video hits an error, suppress controls if it + // hasn't managed to do anything else yet. + if (!this.firstFrameShown && this.hasError()) { + enabled = false; + } + + return enabled; + }, + + updateVolume() { + const volume = this.volumeControl.value; + this.setVolume(volume / 100); + }, + + updateVolumeControls() { + var volume = this.video.muted ? 0 : this.video.volume; + var volumePercentage = Math.round(volume * 100); + this.updateMuteButtonState(); + this.volumeControl.value = volumePercentage; + }, + + /* + * We suspend a video element's video decoder if the video + * element is invisible. However, resuming the video decoder + * takes time and we show the throbber UI if it takes more than + * 250 ms. + * + * When an already-suspended video element becomes visible, we + * resume its video decoder immediately and queue a video-only seek + * task to seek the resumed video decoder to the current position; + * meanwhile, we also file a "mozvideoonlyseekbegin" event which + * we used to start the timer here. + * + * Once the queued seek operation is done, we dispatch a + * "canplay" event which indicates that the resuming operation + * is completed. + */ + SHOW_THROBBER_TIMEOUT_MS: 250, + _showThrobberTimer: null, + _delayShowThrobberWhileResumingVideoDecoder() { + this._showThrobberTimer = this.window.setTimeout(() => { + this.statusIcon.setAttribute("type", "throbber"); + // Show the throbber immediately since we have waited for SHOW_THROBBER_TIMEOUT_MS. + // We don't want to wait for another animation delay(750ms) and the + // animation duration(300ms). + this.setupStatusFader(true); + }, this.SHOW_THROBBER_TIMEOUT_MS); + }, + _cancelShowThrobberWhileResumingVideoDecoder() { + if (this._showThrobberTimer) { + this.window.clearTimeout(this._showThrobberTimer); + this._showThrobberTimer = null; + } + }, + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + this.log("Drop untrusted event ----> " + aEvent.type); + return; + } + + this.log("Got event ----> " + aEvent.type); + + if (this.videoEvents.includes(aEvent.type)) { + this.handleVideoEvent(aEvent); + } else { + this.handleControlEvent(aEvent); + } + }, + + handleVideoEvent(aEvent) { + switch (aEvent.type) { + case "play": + this.setPlayButtonState(false); + this.setupStatusFader(); + if ( + !this._triggeredByControls && + this.dynamicControls && + this.isTouchControls + ) { + this.startFadeOut(this.controlBar); + } + if (!this._triggeredByControls) { + this.startFadeOut(this.clickToPlay, true); + } + this._triggeredByControls = false; + break; + case "pause": + // Little white lie: if we've internally paused the video + // while dragging the scrubber, don't change the button state. + if (!this.scrubber.isDragging) { + this.setPlayButtonState(true); + } + this.setupStatusFader(); + break; + case "ended": + this.setPlayButtonState(true); + // We throttle timechange events, so the thumb might not be + // exactly at the end when the video finishes. + this.showPosition( + Math.round(this.video.currentTime * 1000), + Math.round(this.video.duration * 1000) + ); + this.startFadeIn(this.controlBar); + this.setupStatusFader(); + break; + case "volumechange": + this.updateVolumeControls(); + // Show the controls to highlight the changing volume, + // but only if the click-to-play overlay has already + // been hidden (we don't hide controls when the overlay is visible). + if (this.clickToPlay.hidden && !this.isAudioOnly) { + this.startFadeIn(this.controlBar); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + break; + case "loadedmetadata": + // If a <video> doesn't have any video data, treat it as <audio> + // and show the controls (they won't fade back out) + if ( + this.video.localName == "video" && + (this.video.videoWidth == 0 || this.video.videoHeight == 0) + ) { + this.isAudioOnly = true; + this.startFadeOut(this.clickToPlay, true); + this.startFadeIn(this.controlBar); + this.setFullscreenButtonState(); + } + this.showPosition( + Math.round(this.video.currentTime * 1000), + Math.round(this.video.duration * 1000) + ); + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.disabled = true; + } + this.adjustControlSize(); + this.updatePictureInPictureToggleDisplay(); + break; + case "durationchange": + this.updatePictureInPictureToggleDisplay(); + break; + case "loadeddata": + this.firstFrameShown = true; + this.setupStatusFader(); + break; + case "loadstart": + this.maxCurrentTimeSeen = 0; + this.controlsSpacer.removeAttribute("aria-label"); + this.statusOverlay.removeAttribute("status"); + this.statusIcon.setAttribute("type", "throbber"); + this.isAudioOnly = this.video.localName == "audio"; + this.setPlayButtonState(true); + this.setupNewLoadState(); + this.setupStatusFader(); + break; + case "progress": + this.statusIcon.removeAttribute("stalled"); + this.showBuffered(); + this.setupStatusFader(); + break; + case "stalled": + this.statusIcon.setAttribute("stalled", "true"); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "suspend": + this.setupStatusFader(); + break; + case "timeupdate": + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + var duration = Math.round(this.video.duration * 1000); // in ms + + // If playing/seeking after the video ended, we won't get a "play" + // event, so update the button state here. + if (!this.video.paused) { + this.setPlayButtonState(false); + } + + this.timeUpdateCount++; + // Whether we show the statusOverlay sometimes depends + // on whether we've seen more than one timeupdate + // event (if we haven't, there hasn't been any + // "playback activity" and we may wish to show the + // statusOverlay while we wait for HAVE_ENOUGH_DATA). + // If we've seen more than 2 timeupdate events, + // the count is no longer relevant to setupStatusFader. + if (this.timeUpdateCount <= 2) { + this.setupStatusFader(); + } + + // If the user is dragging the scrubber ignore the delayed seek + // responses (don't yank the thumb away from the user) + if (this.scrubber.isDragging) { + return; + } + this.showPosition(currentTime, duration); + this.showBuffered(); + break; + case "emptied": + this.bufferBar.value = 0; + this.showPosition(0, 0); + break; + case "seeking": + this.showBuffered(); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "waiting": + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "seeked": + case "playing": + case "canplay": + case "canplaythrough": + this.setupStatusFader(); + break; + case "error": + // We'll show the error status icon when we receive an error event + // under either of the following conditions: + // 1. The video has its error attribute set; this means we're loading + // from our src attribute, and the load failed, or we we're loading + // from source children and the decode or playback failed after we + // determined our selected resource was playable. + // 2. The video's networkState is NETWORK_NO_SOURCE. This means we we're + // loading from child source elements, but we were unable to select + // any of the child elements for playback during resource selection. + if (this.hasError()) { + this.suppressError = false; + this.startFadeOut(this.clickToPlay, true); + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + // If video hasn't shown anything yet, disable the controls. + if (!this.firstFrameShown && !this.isAudioOnly) { + this.startFadeOut(this.controlBar); + } + this.controlsSpacer.removeAttribute("hideCursor"); + } + break; + case "mozvideoonlyseekbegin": + this._delayShowThrobberWhileResumingVideoDecoder(); + break; + case "mozvideoonlyseekcompleted": + this._cancelShowThrobberWhileResumingVideoDecoder(); + this.setupStatusFader(); + break; + default: + this.log("!!! media event " + aEvent.type + " not handled!"); + } + }, + + handleControlEvent(aEvent) { + switch (aEvent.type) { + case "click": + switch (aEvent.currentTarget) { + case this.muteButton: + this.toggleMute(); + break; + case this.castingButton: + this.toggleCasting(); + break; + case this.closedCaptionButton: + this.toggleClosedCaption(); + break; + case this.fullscreenButton: + this.toggleFullscreen(); + break; + case this.playButton: + case this.clickToPlay: + case this.controlsSpacer: + this.clickToPlayClickHandler(aEvent); + break; + case this.textTrackList: + const index = +aEvent.originalTarget.getAttribute("index"); + this.changeTextTrack(index); + break; + case this.videocontrols: + // Prevent any click event within media controls from dispatching through to video. + aEvent.stopPropagation(); + break; + } + break; + case "dblclick": + this.toggleFullscreen(); + break; + case "resizevideocontrols": + // Since this event come from the layout, this is the only place + // we are sure of that probing into layout won't trigger or force + // reflow. + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = true; + this.updateReflowedDimensions(); + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = false; + this.adjustControlSize(); + this.updatePictureInPictureToggleDisplay(); + break; + case "fullscreenchange": + this.onFullscreenChange(); + break; + case "keypress": + this.keyHandler(aEvent); + break; + case "dragstart": + aEvent.preventDefault(); // prevent dragging of controls image (bug 517114) + break; + case "input": + switch (aEvent.currentTarget) { + case this.scrubber: + this.onScrubberInput(aEvent); + break; + case this.volumeControl: + this.updateVolume(); + break; + } + break; + case "change": + switch (aEvent.currentTarget) { + case this.scrubber: + this.onScrubberChange(aEvent); + break; + case this.video.textTracks: + this.setClosedCaptionButtonState(); + break; + } + break; + case "mouseup": + // add mouseup listener additionally to handle the case that `change` event + // isn't fired when the input value before/after dragging are the same. (bug 1328061) + this.onScrubberChange(aEvent); + break; + case "addtrack": + this.onTextTrackAdd(aEvent); + break; + case "removetrack": + this.onTextTrackRemove(aEvent); + break; + case "media-videoCasting": + this.updateCasting(aEvent.detail); + break; + case "focusin": + // Show the controls to highlight the focused control, but only + // under certain conditions: + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + // The click-to-play overlay must already be hidden (we don't + // hide controls when the overlay is visible). + this.clickToPlay.hidden && + // Don't do this if the controls are static. + this.dynamicControls && + // If the mouse is hovering over the control bar, the controls + // are already showing and they shouldn't hide, so don't mess + // with them. + // We use "div:hover" instead of just ":hover" so this works in + // quirks mode documents. See + // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk + !this.controlBar.matches("div:hover") + ) { + this.startFadeIn(this.controlBar); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + break; + case "mousedown": + // We only listen for mousedown on sliders. + // If this slider isn't focused already, mousedown will focus it. + // We don't want that because it will then handle additional keys. + // For example, we don't want the up/down arrow keys to seek after + // the scrubber is clicked. To prevent that, we need to redirect + // focus. However, dragging only works while the slider is focused, + // so we must redirect focus after mouseup. + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + !aEvent.currentTarget.matches(":focus") + ) { + aEvent.currentTarget.addEventListener( + "mouseup", + aEvent => { + if (aEvent.currentTarget.matches(":focus")) { + // We can't use target.blur() because that will blur the + // video element as well. + this.video.focus(); + } + }, + { once: true } + ); + } + break; + default: + this.log("!!! control event " + aEvent.type + " not handled!"); + } + }, + + terminate() { + if (this.videoEvents) { + for (let event of this.videoEvents) { + try { + this.video.removeEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } catch (ex) {} + } + } + + try { + for (let { el, type, capture = false } of this.controlsEvents) { + el.removeEventListener(type, this, { + mozSystemGroup: true, + capture, + }); + } + } catch (ex) {} + + this.window.clearTimeout(this._showControlsTimeout); + this.window.clearTimeout(this._hideControlsTimeout); + this._cancelShowThrobberWhileResumingVideoDecoder(); + + this.log("--- videocontrols terminated ---"); + }, + + hasError() { + // We either have an explicit error, or the resource selection + // algorithm is running and we've tried to load something and failed. + // Note: we don't consider the case where we've tried to load but + // there's no sources to load as an error condition, as sites may + // do this intentionally to work around requires-user-interaction to + // play restrictions, and we don't want to display a debug message + // if that's the case. + return ( + this.video.error != null || + (this.video.networkState == this.video.NETWORK_NO_SOURCE && + this.hasSources()) + ); + }, + + setShowPictureInPictureMessage(showMessage) { + if (showMessage) { + this.pictureInPictureOverlay.removeAttribute("hidden"); + } else { + this.pictureInPictureOverlay.setAttribute("hidden", true); + } + + this.isShowingPictureInPictureMessage = showMessage; + }, + + hasSources() { + if ( + this.video.hasAttribute("src") && + this.video.getAttribute("src") !== "" + ) { + return true; + } + for ( + var child = this.video.firstChild; + child !== null; + child = child.nextElementSibling + ) { + if (child instanceof this.window.HTMLSourceElement) { + return true; + } + } + return false; + }, + + updateErrorText() { + let error; + let v = this.video; + // It is possible to have both v.networkState == NETWORK_NO_SOURCE + // as well as v.error being non-null. In this case, we will show + // the v.error.code instead of the v.networkState error. + if (v.error) { + switch (v.error.code) { + case v.error.MEDIA_ERR_ABORTED: + error = "errorAborted"; + break; + case v.error.MEDIA_ERR_NETWORK: + error = "errorNetwork"; + break; + case v.error.MEDIA_ERR_DECODE: + error = "errorDecode"; + break; + case v.error.MEDIA_ERR_SRC_NOT_SUPPORTED: + error = + v.networkState == v.NETWORK_NO_SOURCE + ? "errorNoSource" + : "errorSrcNotSupported"; + break; + default: + error = "errorGeneric"; + break; + } + } else if (v.networkState == v.NETWORK_NO_SOURCE) { + error = "errorNoSource"; + } else { + return; // No error found. + } + + let label = this.shadowRoot.getElementById(error); + this.controlsSpacer.setAttribute("aria-label", label.textContent); + this.statusOverlay.setAttribute("status", error); + }, + + formatTime(aTime, showHours = false) { + // Format the duration as "h:mm:ss" or "m:ss" + aTime = Math.round(aTime / 1000); + let hours = Math.floor(aTime / 3600); + let mins = Math.floor((aTime % 3600) / 60); + let secs = Math.floor(aTime % 60); + let timeString; + if (secs < 10) { + secs = "0" + secs; + } + if (hours || showHours) { + if (mins < 10) { + mins = "0" + mins; + } + timeString = hours + ":" + mins + ":" + secs; + } else { + timeString = mins + ":" + secs; + } + return timeString; + }, + + initPositionDurationBox() { + const positionTextNode = Array.prototype.find.call( + this.positionDurationBox.childNodes, + n => !!~n.textContent.search("#1") + ); + const durationSpan = this.durationSpan; + const durationFormat = durationSpan.textContent; + const positionFormat = positionTextNode.textContent; + + durationSpan.classList.add("duration"); + durationSpan.setAttribute("role", "none"); + durationSpan.id = "durationSpan"; + + Object.defineProperties(this.positionDurationBox, { + durationSpan: { + value: durationSpan, + }, + position: { + set: v => { + positionTextNode.textContent = positionFormat.replace("#1", v); + }, + }, + duration: { + set: v => { + durationSpan.textContent = v + ? durationFormat.replace("#2", v) + : ""; + }, + }, + }); + }, + + showDuration(duration) { + let isInfinite = duration == Infinity; + this.log("Duration is " + duration + "ms.\n"); + + if (isNaN(duration) || isInfinite) { + duration = this.maxCurrentTimeSeen; + } + + // If the duration is over an hour, thumb should show h:mm:ss instead of mm:ss + this.showHours = duration >= 3600000; + + // Format the duration as "h:mm:ss" or "m:ss" + let timeString = isInfinite ? "" : this.formatTime(duration); + this.positionDurationBox.duration = timeString; + + if (this.showHours) { + this.positionDurationBox.modifier = "long"; + this.durationSpan.modifier = "long"; + } + + this.scrubber.max = duration; + }, + + pauseVideoDuringDragging() { + if ( + !this.video.paused && + !this.isPausedByDragging && + this.scrubber.isDragging + ) { + this.isPausedByDragging = true; + this.video.pause(); + } + }, + + onScrubberInput(e) { + const duration = Math.round(this.video.duration * 1000); // in ms + let time = this.scrubber.value; + + this.seekToPosition(time); + this.showPosition(time, duration); + + this.scrubber.isDragging = true; + this.pauseVideoDuringDragging(); + }, + + onScrubberChange(e) { + this.scrubber.isDragging = false; + + if (this.isPausedByDragging) { + this.video.play(); + this.isPausedByDragging = false; + } + }, + + updateScrubberProgress() { + const positionPercent = (this.scrubber.value / this.scrubber.max) * 100; + + if (!isNaN(positionPercent) && positionPercent != Infinity) { + this.progressBar.value = positionPercent; + } else { + this.progressBar.value = 0; + } + }, + + seekToPosition(newPosition) { + newPosition /= 1000; // convert from ms + this.log("+++ seeking to " + newPosition); + this.video.currentTime = newPosition; + }, + + setVolume(newVolume) { + this.log("*** setting volume to " + newVolume); + this.video.volume = newVolume; + this.video.muted = false; + }, + + showPosition(currentTime, duration) { + // If the duration is unknown (because the server didn't provide + // it, or the video is a stream), then we want to fudge the duration + // by using the maximum playback position that's been seen. + if (currentTime > this.maxCurrentTimeSeen) { + this.maxCurrentTimeSeen = currentTime; + } + this.showDuration(duration); + + this.log("time update @ " + currentTime + "ms of " + duration + "ms"); + + let positionTime = this.formatTime(currentTime, this.showHours); + + this.scrubber.value = currentTime; + this.positionDurationBox.position = positionTime; + this.scrubber.setAttribute( + "aria-valuetext", + this.positionDurationBox.textContent.trim() + ); + this.updateScrubberProgress(); + }, + + showBuffered() { + function bsearch(haystack, needle, cmp) { + var length = haystack.length; + var low = 0; + var high = length; + while (low < high) { + var probe = low + ((high - low) >> 1); + var r = cmp(haystack, probe, needle); + if (r == 0) { + return probe; + } else if (r > 0) { + low = probe + 1; + } else { + high = probe; + } + } + return -1; + } + + function bufferedCompare(buffered, i, time) { + if (time > buffered.end(i)) { + return 1; + } else if (time >= buffered.start(i)) { + return 0; + } + return -1; + } + + var duration = Math.round(this.video.duration * 1000); + if (isNaN(duration) || duration == Infinity) { + duration = this.maxCurrentTimeSeen; + } + + // Find the range that the current play position is in and use that + // range for bufferBar. At some point we may support multiple ranges + // displayed in the bar. + var currentTime = this.video.currentTime; + var buffered = this.video.buffered; + var index = bsearch(buffered, currentTime, bufferedCompare); + var endTime = 0; + if (index >= 0) { + endTime = Math.round(buffered.end(index) * 1000); + } + this.bufferBar.max = duration; + this.bufferBar.value = endTime; + // Progress bars are automatically reported by screen readers even when + // they aren't focused, which intrudes on the audio being played. + // Ideally, we'd just change the a11y role of bufferBar, but there's + // no role which will let us just expose text via an ARIA attribute. + // Therefore, we hide bufferBar for a11y and expose the info as + // off-screen text. + this.bufferA11yVal.textContent = + (this.bufferBar.position * 100).toFixed() + "%"; + }, + + _controlsHiddenByTimeout: false, + _showControlsTimeout: 0, + SHOW_CONTROLS_TIMEOUT_MS: 500, + _showControlsFn() { + if (this.video.matches("video:hover")) { + this.startFadeIn(this.controlBar, false); + this._showControlsTimeout = 0; + this._controlsHiddenByTimeout = false; + } + }, + + _hideControlsTimeout: 0, + _hideControlsFn() { + if (!this.scrubber.isDragging) { + this.startFade(this.controlBar, false); + this._hideControlsTimeout = 0; + this._controlsHiddenByTimeout = true; + } + }, + HIDE_CONTROLS_TIMEOUT_MS: 2000, + + // By "Video" we actually mean the video controls container, + // because we don't want to consider the padding of <video> added + // by the web content. + isMouseOverVideo(event) { + // XXX: this triggers reflow too, but the layout should only be dirty + // if the web content touches it while the mouse is moving. + let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY); + + // As long as this is not null, the cursor is over something within our + // Shadow DOM. + return !!el; + }, + + isMouseOverControlBar(event) { + // XXX: this triggers reflow too, but the layout should only be dirty + // if the web content touches it while the mouse is moving. + let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY); + while (el && el !== this.shadowRoot) { + if (el == this.controlBar) { + return true; + } + el = el.parentNode; + } + return false; + }, + + onMouseMove(event) { + // If the controls are static, don't change anything. + if (!this.dynamicControls) { + return; + } + + this.window.clearTimeout(this._hideControlsTimeout); + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if (!this.firstFrameShown && !this.video.autoplay) { + return; + } + + if (this._controlsHiddenByTimeout) { + this._showControlsTimeout = this.window.setTimeout( + () => this._showControlsFn(), + this.SHOW_CONTROLS_TIMEOUT_MS + ); + } else { + this.startFade(this.controlBar, true); + } + + // Hide the controls if the mouse cursor is left on top of the video + // but above the control bar and if the click-to-play overlay is hidden. + if ( + (this._controlsHiddenByTimeout || + !this.isMouseOverControlBar(event)) && + this.clickToPlay.hidden + ) { + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + }, + + onMouseInOut(event) { + // If the controls are static, don't change anything. + if (!this.dynamicControls) { + return; + } + + this.window.clearTimeout(this._hideControlsTimeout); + + let isMouseOverVideo = this.isMouseOverVideo(event); + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if ( + !this.firstFrameShown && + !isMouseOverVideo && + !this.video.autoplay + ) { + return; + } + + if (!isMouseOverVideo && !this.isMouseOverControlBar(event)) { + this.adjustControlSize(); + + // Keep the controls visible if the click-to-play is visible. + if (!this.clickToPlay.hidden) { + return; + } + + this.startFadeOut(this.controlBar, false); + this.textTrackListContainer.hidden = true; + this.window.clearTimeout(this._showControlsTimeout); + this._controlsHiddenByTimeout = false; + } + }, + + startFadeIn(element, immediate) { + this.startFade(element, true, immediate); + }, + + startFadeOut(element, immediate) { + this.startFade(element, false, immediate); + }, + + animationMap: new WeakMap(), + + animationProps: { + clickToPlay: { + keyframes: [ + { transform: "scale(3)", opacity: 0 }, + { transform: "scale(1)", opacity: 0.55 }, + ], + options: { + easing: "ease", + duration: 400, + // The fill mode here and below is a workaround to avoid flicker + // due to bug 1495350. + fill: "both", + }, + }, + controlBar: { + keyframes: [{ opacity: 0 }, { opacity: 1 }], + options: { + easing: "ease", + duration: 200, + fill: "both", + }, + }, + statusOverlay: { + keyframes: [ + { opacity: 0 }, + { opacity: 0, offset: 0.72 }, // ~750ms into animation + { opacity: 1 }, + ], + options: { + duration: 1050, + fill: "both", + }, + }, + }, + + startFade(element, fadeIn, immediate = false) { + let animationProp = this.animationProps[element.id]; + if (!animationProp) { + throw new Error( + "Element " + + element.id + + " has no transition. Toggle the hidden property directly." + ); + } + + let animation = this.animationMap.get(element); + if (!animation) { + animation = new this.window.Animation( + new this.window.KeyframeEffect( + element, + animationProp.keyframes, + animationProp.options + ) + ); + + this.animationMap.set(element, animation); + } + + if (fadeIn) { + if (element == this.controlBar) { + this.controlsSpacer.removeAttribute("hideCursor"); + // Ensure the Full Screen button is in the tab order. + this.fullscreenButton.removeAttribute("tabindex"); + } + + // hidden state should be controlled by adjustControlSize + if (element.isAdjustableControl && element.hiddenByAdjustment) { + return; + } + + // No need to fade in again if the hidden property returns false + // (not hidden and not fading out.) + if (!element.hidden) { + return; + } + + // Unhide + element.hidden = false; + } else { + if (element == this.controlBar) { + if (!this.hasError() && this.isVideoInFullScreen) { + this.controlsSpacer.setAttribute("hideCursor", true); + } + if ( + !this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] + ) { + // The Full Screen button is currently the only tabbable button + // when the controls are shown. Remove it from the tab order when + // visually hidden to prevent visual confusion. + this.fullscreenButton.setAttribute("tabindex", "-1"); + } + } + + // No need to fade out if the hidden property returns true + // (hidden or is fading out) + if (element.hidden) { + return; + } + } + + element.classList.toggle("fadeout", !fadeIn); + element.classList.toggle("fadein", fadeIn); + let finishedPromise; + if (!immediate) { + // At this point, if there is a pending animation, we just stop it to avoid it happening. + // If there is a running animation, we reverse it, to have it rewind to the beginning. + // If there is an idle/finished animation, we schedule a new one that reverses the finished one. + if (animation.pending) { + // Animation is running but pending. + // Just cancel the pending animation to stop its effect. + animation.cancel(); + finishedPromise = Promise.resolve(); + } else { + switch (animation.playState) { + case "idle": + case "finished": + // There is no animation currently playing. + // Schedule a new animation with the desired playback direction. + animation.playbackRate = fadeIn ? 1 : -1; + animation.play(); + break; + case "running": + // Allow the animation to play from its current position in + // reverse to finish. + animation.reverse(); + break; + case "pause": + throw new Error("Animation should never reach pause state."); + default: + throw new Error( + "Unknown Animation playState: " + animation.playState + ); + } + finishedPromise = animation.finished; + } + } else { + // immediate + animation.cancel(); + finishedPromise = Promise.resolve(); + } + finishedPromise.then( + animation => { + if (element == this.controlBar) { + this.onControlBarAnimationFinished(); + } + element.classList.remove(fadeIn ? "fadein" : "fadeout"); + if (!fadeIn) { + element.hidden = true; + } + if (animation) { + // Explicitly clear the animation effect so that filling animations + // stop overwriting stylesheet styles. Remove when bug 1495350 is + // fixed and animations are no longer filling animations. + // This also stops them from accumulating (See bug 1253476). + animation.cancel(); + } + }, + () => { + /* Do nothing on rejection */ + } + ); + }, + + _triggeredByControls: false, + + startPlay() { + this._triggeredByControls = true; + this.hideClickToPlay(); + this.video.play(); + }, + + togglePause() { + if (this.video.paused || this.video.ended) { + this.startPlay(); + } else { + this.video.pause(); + } + + // We'll handle style changes in the event listener for + // the "play" and "pause" events, same as if content + // script was controlling video playback. + }, + + get isVideoWithoutAudioTrack() { + return ( + this.video.readyState >= this.video.HAVE_METADATA && + !this.isAudioOnly && + !this.video.mozHasAudio + ); + }, + + toggleMute() { + if (this.isVideoWithoutAudioTrack) { + return; + } + this.video.muted = !this.isEffectivelyMuted; + if (this.video.volume === 0) { + this.video.volume = 0.5; + } + + // We'll handle style changes in the event listener for + // the "volumechange" event, same as if content script was + // controlling volume. + }, + + get isVideoInFullScreen() { + return this.video.isSameNode( + this.video.getRootNode().fullscreenElement + ); + }, + + toggleFullscreen() { + this.isVideoInFullScreen + ? this.document.exitFullscreen() + : this.video.requestFullscreen(); + }, + + setFullscreenButtonState() { + if (this.isAudioOnly || !this.document.fullscreenEnabled) { + this.controlBar.setAttribute("fullscreen-unavailable", true); + this.adjustControlSize(); + return; + } + this.controlBar.removeAttribute("fullscreen-unavailable"); + this.adjustControlSize(); + + var attrName = this.isVideoInFullScreen + ? "exitfullscreenlabel" + : "enterfullscreenlabel"; + var value = this.fullscreenButton.getAttribute(attrName); + this.fullscreenButton.setAttribute("aria-label", value); + + if (this.isVideoInFullScreen) { + this.fullscreenButton.setAttribute("fullscreened", "true"); + } else { + this.fullscreenButton.removeAttribute("fullscreened"); + } + }, + + onFullscreenChange() { + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } else { + this.videocontrols.removeAttribute("inDOMFullscreen"); + } + + this.updateOrientationState(this.isVideoInFullScreen); + + if (this.isVideoInFullScreen) { + this.startFadeOut(this.controlBar, true); + } + + this.setFullscreenButtonState(); + }, + + updateOrientationState(lock) { + if (!this.video.mozOrientationLockEnabled) { + return; + } + if (lock) { + if (this.video.mozIsOrientationLocked) { + return; + } + let dimenDiff = this.video.videoWidth - this.video.videoHeight; + if (dimenDiff > 0) { + this.video.mozIsOrientationLocked = this.window.screen.mozLockOrientation( + "landscape" + ); + } else if (dimenDiff < 0) { + this.video.mozIsOrientationLocked = this.window.screen.mozLockOrientation( + "portrait" + ); + } else { + this.video.mozIsOrientationLocked = this.window.screen.mozLockOrientation( + this.window.screen.orientation + ); + } + } else { + if (!this.video.mozIsOrientationLocked) { + return; + } + this.window.screen.mozUnlockOrientation(); + this.video.mozIsOrientationLocked = false; + } + }, + + clickToPlayClickHandler(e) { + if (e.button != 0) { + return; + } + if (this.hasError() && !this.suppressError) { + // Errors that can be dismissed should be placed here as we discover them. + if (this.video.error.code != this.video.error.MEDIA_ERR_ABORTED) { + return; + } + this.startFadeOut(this.statusOverlay, true); + this.suppressError = true; + return; + } + if (e.defaultPrevented) { + return; + } + if (this.playButton.hasAttribute("paused")) { + this.startPlay(); + } else { + this.video.pause(); + } + }, + hideClickToPlay() { + let videoHeight = this.reflowedDimensions.videoHeight; + let videoWidth = this.reflowedDimensions.videoWidth; + + // The play button will animate to 3x its size. This + // shows the animation unless the video is too small + // to show 2/3 of the animation. + let animationScale = 2; + let animationMinSize = this.clickToPlay.minWidth * animationScale; + + let immediate = + animationMinSize > videoWidth || + animationMinSize > videoHeight - this.controlBarMinHeight; + this.startFadeOut(this.clickToPlay, immediate); + }, + + setPlayButtonState(aPaused) { + if (aPaused) { + this.playButton.setAttribute("paused", "true"); + } else { + this.playButton.removeAttribute("paused"); + } + + var attrName = aPaused ? "playlabel" : "pauselabel"; + var value = this.playButton.getAttribute(attrName); + this.playButton.setAttribute("aria-label", value); + }, + + get isEffectivelyMuted() { + return this.video.muted || !this.video.volume; + }, + + updateMuteButtonState() { + var muted = this.isEffectivelyMuted; + + if (muted) { + this.muteButton.setAttribute("muted", "true"); + } else { + this.muteButton.removeAttribute("muted"); + } + + var attrName = muted ? "unmutelabel" : "mutelabel"; + var value = this.muteButton.getAttribute(attrName); + this.muteButton.setAttribute("aria-label", value); + }, + + keyboardVolumeDecrease() { + const oldval = this.video.volume; + this.video.volume = oldval < 0.1 ? 0 : oldval - 0.1; + this.video.muted = false; + }, + + keyboardVolumeIncrease() { + const oldval = this.video.volume; + this.video.volume = oldval > 0.9 ? 1 : oldval + 0.1; + this.video.muted = false; + }, + + keyboardSeekBack(tenPercent) { + const oldval = this.video.currentTime; + let newval; + if (tenPercent) { + newval = + oldval - + (this.video.duration || this.maxCurrentTimeSeen / 1000) / 10; + } else { + newval = oldval - 15; + } + this.video.currentTime = Math.max(0, newval); + }, + + keyboardSeekForward(tenPercent) { + const oldval = this.video.currentTime; + const maxtime = this.video.duration || this.maxCurrentTimeSeen / 1000; + let newval; + if (tenPercent) { + newval = oldval + maxtime / 10; + } else { + newval = oldval + 15; + } + this.video.currentTime = Math.min(newval, maxtime); + }, + + keyHandler(event) { + // Ignore keys when content might be providing its own. + if (!this.video.hasAttribute("controls")) { + return; + } + + let keystroke = ""; + if (event.altKey) { + keystroke += "alt-"; + } + if (event.shiftKey) { + keystroke += "shift-"; + } + if (this.window.navigator.platform.startsWith("Mac")) { + if (event.metaKey) { + keystroke += "accel-"; + } + if (event.ctrlKey) { + keystroke += "control-"; + } + } else { + if (event.metaKey) { + keystroke += "meta-"; + } + if (event.ctrlKey) { + keystroke += "accel-"; + } + } + if (event.key == " ") { + keystroke += "Space"; + } else { + keystroke += event.key; + } + + this.log("Got keystroke: " + keystroke); + + // If unmodified cursor keys are pressed when a slider is focused, we + // should act on that slider. For example, if we're focused on the + // volume slider, rightArrow should increase the volume, not seek. + // Normally, we'd just pass the keys through to the slider in this case. + // However, the native adjustment is too small, so we override it. + try { + const target = event.originalTarget; + const allTabbable = this.prefs[ + "media.videocontrols.keyboard-tab-to-all-controls" + ]; + switch (keystroke) { + case "Space" /* Play */: + if (target.localName === "button" && !target.disabled) { + break; + } + this.togglePause(); + break; + case "ArrowDown" /* Volume decrease */: + if (allTabbable && target == this.scrubber) { + this.keyboardSeekBack(/* tenPercent */ false); + } else { + this.keyboardVolumeDecrease(); + } + break; + case "ArrowUp" /* Volume increase */: + if (allTabbable && target == this.scrubber) { + this.keyboardSeekForward(/* tenPercent */ false); + } else { + this.keyboardVolumeIncrease(); + } + break; + case "accel-ArrowDown" /* Mute */: + this.video.muted = true; + break; + case "accel-ArrowUp" /* Unmute */: + this.video.muted = false; + break; + case "ArrowLeft" /* Seek back 15 seconds */: + if (allTabbable && target == this.volumeControl) { + this.keyboardVolumeDecrease(); + } else { + this.keyboardSeekBack(/* tenPercent */ false); + } + break; + case "accel-ArrowLeft" /* Seek back 10% */: + this.keyboardSeekBack(/* tenPercent */ true); + break; + case "ArrowRight" /* Seek forward 15 seconds */: + if (allTabbable && target == this.volumeControl) { + this.keyboardVolumeIncrease(); + } else { + this.keyboardSeekForward(/* tenPercent */ false); + } + break; + case "accel-ArrowRight" /* Seek forward 10% */: + this.keyboardSeekForward(/* tenPercent */ true); + break; + case "Home" /* Seek to beginning */: + this.video.currentTime = 0; + break; + case "End" /* Seek to end */: + if (this.video.currentTime != this.video.duration) { + this.video.currentTime = + this.video.duration || this.maxCurrentTimeSeen / 1000; + } + break; + default: + return; + } + } catch (e) { + /* ignore any exception from setting .currentTime */ + } + + event.preventDefault(); // Prevent page scrolling + }, + + checkTextTrackSupport(textTrack) { + return textTrack.kind == "subtitles" || textTrack.kind == "captions"; + }, + + get isCastingAvailable() { + return !this.isAudioOnly && this.video.mozAllowCasting; + }, + + get isClosedCaptionAvailable() { + // There is no rendering area, no need to show the caption. + if (this.isAudioOnly) { + return false; + } + return this.overlayableTextTracks.length; + }, + + get overlayableTextTracks() { + return Array.prototype.filter.call( + this.video.textTracks, + this.checkTextTrackSupport + ); + }, + + get currentTextTrackIndex() { + const showingTT = this.overlayableTextTracks.find( + tt => tt.mode == "showing" + ); + + // fallback to off button if there's no showing track. + return showingTT ? showingTT.index : 0; + }, + + get isCastingOn() { + return this.isCastingAvailable && this.video.mozIsCasting; + }, + + setCastingButtonState() { + if (this.isCastingOn) { + this.castingButton.setAttribute("enabled", "true"); + } else { + this.castingButton.removeAttribute("enabled"); + } + + this.adjustControlSize(); + }, + + updateCasting(eventDetail) { + let castingData = JSON.parse(eventDetail); + if ("allow" in castingData) { + this.video.mozAllowCasting = !!castingData.allow; + } + + if ("active" in castingData) { + this.video.mozIsCasting = !!castingData.active; + } + this.setCastingButtonState(); + }, + + get isClosedCaptionOn() { + for (let tt of this.overlayableTextTracks) { + if (tt.mode === "showing") { + return true; + } + } + + return false; + }, + + setClosedCaptionButtonState() { + if (this.isClosedCaptionOn) { + this.closedCaptionButton.setAttribute("enabled", "true"); + } else { + this.closedCaptionButton.removeAttribute("enabled"); + } + + let ttItems = this.textTrackList.childNodes; + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx == this.currentTextTrackIndex) { + tti.setAttribute("on", "true"); + } else { + tti.removeAttribute("on"); + } + } + + this.adjustControlSize(); + }, + + addNewTextTrack(tt) { + if (!this.checkTextTrackSupport(tt)) { + return; + } + + if (tt.index && tt.index < this.textTracksCount) { + // Don't create items for initialized tracks. However, we + // still need to care about mode since TextTrackManager would + // turn on the first available track automatically. + if (tt.mode === "showing") { + this.changeTextTrack(tt.index); + } + return; + } + + tt.index = this.textTracksCount++; + + const ttBtn = this.shadowRoot.createElementAndAppendChildAt( + this.textTrackList, + "button" + ); + ttBtn.textContent = tt.label || ""; + + ttBtn.classList.add("textTrackItem"); + ttBtn.setAttribute("index", tt.index); + + if (tt.mode === "showing" && tt.index) { + this.changeTextTrack(tt.index); + } + }, + + changeTextTrack(index) { + for (let tt of this.overlayableTextTracks) { + if (tt.index === index) { + tt.mode = "showing"; + } else { + tt.mode = "disabled"; + } + } + + if (!this.textTrackListContainer.hidden) { + this.toggleClosedCaption(); + } + }, + + onControlBarAnimationFinished() { + this.textTrackListContainer.hidden = true; + this.video.dispatchEvent( + new this.window.CustomEvent("controlbarchange") + ); + this.adjustControlSize(); + }, + + toggleCasting() { + this.videocontrols.dispatchEvent( + new this.window.CustomEvent("VideoBindingCast") + ); + }, + + toggleClosedCaption() { + if (this.textTrackListContainer.hidden) { + this.textTrackListContainer.hidden = false; + if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) { + // If we're about to hide the controls after focus, prevent that, as + // that will dismiss the CC menu before the user can use it. + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = 0; + } + } else { + this.textTrackListContainer.hidden = true; + // If the CC menu was shown via the keyboard, we may have prevented + // the controls from hiding. We can now hide them. + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + !this.controlBar.hidden && + // The click-to-play overlay must already be hidden (we don't + // hide controls when the overlay is visible). + this.clickToPlay.hidden && + // Don't do this if the controls are static. + this.dynamicControls && + // If the mouse is hovering over the control bar, the controls + // shouldn't hide. + // We use "div:hover" instead of just ":hover" so this works in + // quirks mode documents. See + // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk + !this.controlBar.matches("div:hover") + ) { + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + } + }, + + onTextTrackAdd(trackEvent) { + this.addNewTextTrack(trackEvent.track); + this.setClosedCaptionButtonState(); + }, + + onTextTrackRemove(trackEvent) { + const toRemoveIndex = trackEvent.track.index; + const ttItems = this.textTrackList.childNodes; + + if (!ttItems) { + return; + } + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx === toRemoveIndex) { + tti.remove(); + this.textTracksCount--; + } + + this.video.dispatchEvent( + new this.window.CustomEvent("texttrackchange") + ); + } + + this.setClosedCaptionButtonState(); + }, + + initTextTracks() { + // add 'off' button anyway as new text track might be + // dynamically added after initialization. + const offLabel = this.textTrackList.getAttribute("offlabel"); + this.addNewTextTrack({ + label: offLabel, + kind: "subtitles", + }); + + for (let tt of this.overlayableTextTracks) { + this.addNewTextTrack(tt); + } + + this.setClosedCaptionButtonState(); + }, + + log(msg) { + if (this.debug) { + this.window.console.log("videoctl: " + msg + "\n"); + } + }, + + get isTopLevelSyntheticDocument() { + return ( + this.document.mozSyntheticDocument && this.window === this.window.top + ); + }, + + controlBarMinHeight: 40, + controlBarMinVisibleHeight: 28, + + reflowTriggeringCallValidator: { + isReflowTriggeringPropsAllowed: false, + reflowTriggeringProps: Object.freeze([ + "offsetLeft", + "offsetTop", + "offsetWidth", + "offsetHeight", + "offsetParent", + "clientLeft", + "clientTop", + "clientWidth", + "clientHeight", + "getClientRects", + "getBoundingClientRect", + ]), + get(obj, prop) { + if ( + !this.isReflowTriggeringPropsAllowed && + this.reflowTriggeringProps.includes(prop) + ) { + throw new Error("Please don't trigger reflow. See bug 1493525."); + } + let val = obj[prop]; + if (typeof val == "function") { + return function() { + return val.apply(obj, arguments); + }; + } + return val; + }, + + set(obj, prop, value) { + return Reflect.set(obj, prop, value); + }, + }, + + installReflowCallValidator(element) { + return new Proxy(element, this.reflowTriggeringCallValidator); + }, + + reflowedDimensions: { + // Set the dimensions to intrinsic <video> dimensions before the first + // update. + // These values are not picked up by <audio> in adjustControlSize() + // (except for the fact that they are non-zero), + // it takes controlBarMinHeight and the value below instead. + videoHeight: 150, + videoWidth: 300, + + // <audio> takes this width to grow/shrink controls. + // The initial value has to be smaller than the calculated minRequiredWidth + // so that we don't run into bug 1495821 (see comment on adjustControlSize() + // below) + videocontrolsWidth: 0, + }, + + updateReflowedDimensions() { + this.reflowedDimensions.videoHeight = this.video.clientHeight; + this.reflowedDimensions.videoWidth = this.video.clientWidth; + this.reflowedDimensions.videocontrolsWidth = this.videocontrols.clientWidth; + }, + + /** + * adjustControlSize() considers outer dimensions of the <video>/<audio> element + * from layout, and accordingly, sets/hides the controls, and adjusts + * the width/height of the control bar. + * + * It's important to remember that for <audio>, layout (specifically, + * nsVideoFrame) rely on us to expose the intrinsic dimensions of the + * control bar to properly size the <audio> element. We interact with layout + * by: + * + * 1) When the element has a non-zero height, explicitly set the height + * of the control bar to a size between controlBarMinHeight and + * controlBarMinVisibleHeight in response. + * Note: the logic here is flawed and had caused the end height to be + * depend on its previous state, see bug 1495817. + * 2) When the element has a outer width smaller or equal to minControlBarPaddingWidth, + * explicitly set the control bar to minRequiredWidth, so that when the + * outer width is unset, the audio element could go back to minRequiredWidth. + * Otherwise, set the width of the control bar to be the current outer width. + * Note: the logic here is also flawed; when the control bar is set to + * the current outer width, it never go back when the width is unset, + * see bug 1495821. + */ + adjustControlSize() { + const minControlBarPaddingWidth = 18; + + this.fullscreenButton.isWanted = !this.controlBar.hasAttribute( + "fullscreen-unavailable" + ); + this.castingButton.isWanted = this.isCastingAvailable; + this.closedCaptionButton.isWanted = this.isClosedCaptionAvailable; + this.volumeStack.isWanted = !this.muteButton.hasAttribute("noAudio"); + + let minRequiredWidth = this.prioritizedControls + .filter(control => control && control.isWanted) + .reduce( + (accWidth, cc) => accWidth + cc.minWidth, + minControlBarPaddingWidth + ); + // Skip the adjustment in case the stylesheets haven't been loaded yet. + if (!minRequiredWidth) { + return; + } + + let givenHeight = this.reflowedDimensions.videoHeight; + let videoWidth = + (this.isAudioOnly + ? this.reflowedDimensions.videocontrolsWidth + : this.reflowedDimensions.videoWidth) || minRequiredWidth; + let videoHeight = this.isAudioOnly + ? this.controlBarMinHeight + : givenHeight; + let videocontrolsWidth = this.reflowedDimensions.videocontrolsWidth; + + let widthUsed = minControlBarPaddingWidth; + let preventAppendControl = false; + + for (let control of this.prioritizedControls) { + if (!control.isWanted) { + control.hiddenByAdjustment = true; + continue; + } + + control.hiddenByAdjustment = + preventAppendControl || widthUsed + control.minWidth > videoWidth; + + if (control.hiddenByAdjustment) { + preventAppendControl = true; + } else { + widthUsed += control.minWidth; + } + } + + // Use flexible spacer to separate controls when scrubber is hidden. + // As long as muteButton hidden, which means only play button presents, + // hide spacer and make playButton centered. + this.controlBarSpacer.hidden = + !this.scrubberStack.hidden || this.muteButton.hidden; + + // Since the size of videocontrols is expanded with controlBar in <audio>, we + // should fix the dimensions in order not to recursively trigger reflow afterwards. + if (this.video.localName == "audio") { + if (givenHeight) { + // The height of controlBar should be capped with the bounds between controlBarMinHeight + // and controlBarMinVisibleHeight. + let controlBarHeight = Math.max( + Math.min(givenHeight, this.controlBarMinHeight), + this.controlBarMinVisibleHeight + ); + this.controlBar.style.height = `${controlBarHeight}px`; + } + // Bug 1367875: Set minimum required width to controlBar if the given size is smaller than padding. + // This can help us expand the control and restore to the default size the next time we need + // to adjust the sizing. + if (videocontrolsWidth <= minControlBarPaddingWidth) { + this.controlBar.style.width = `${minRequiredWidth}px`; + } else { + this.controlBar.style.width = `${videoWidth}px`; + } + return; + } + + if ( + videoHeight < this.controlBarMinHeight || + widthUsed === minControlBarPaddingWidth + ) { + this.controlBar.setAttribute("size", "hidden"); + this.controlBar.hiddenByAdjustment = true; + } else { + this.controlBar.removeAttribute("size"); + this.controlBar.hiddenByAdjustment = false; + } + + // Adjust clickToPlayButton size. + const minVideoSideLength = Math.min(videoWidth, videoHeight); + const clickToPlayViewRatio = 0.15; + const clickToPlayScaledSize = Math.max( + this.clickToPlay.minWidth, + minVideoSideLength * clickToPlayViewRatio + ); + + if ( + clickToPlayScaledSize >= videoWidth || + clickToPlayScaledSize + this.controlBarMinHeight / 2 >= + videoHeight / 2 + ) { + this.clickToPlay.hiddenByAdjustment = true; + } else { + if ( + this.clickToPlay.hidden && + !this.video.played.length && + this.video.paused + ) { + this.clickToPlay.hiddenByAdjustment = false; + } + this.clickToPlay.style.width = `${clickToPlayScaledSize}px`; + this.clickToPlay.style.height = `${clickToPlayScaledSize}px`; + } + }, + + get pipToggleEnabled() { + return this.prefs[ + "media.videocontrols.picture-in-picture.video-toggle.enabled" + ]; + }, + + init(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.video = this.installReflowCallValidator(shadowRoot.host); + this.videocontrols = this.installReflowCallValidator( + shadowRoot.firstChild + ); + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + this.prefs = prefs; + + this.controlsContainer = this.shadowRoot.getElementById( + "controlsContainer" + ); + this.statusIcon = this.shadowRoot.getElementById("statusIcon"); + this.controlBar = this.shadowRoot.getElementById("controlBar"); + this.playButton = this.shadowRoot.getElementById("playButton"); + this.controlBarSpacer = this.shadowRoot.getElementById( + "controlBarSpacer" + ); + this.muteButton = this.shadowRoot.getElementById("muteButton"); + this.volumeStack = this.shadowRoot.getElementById("volumeStack"); + this.volumeControl = this.shadowRoot.getElementById("volumeControl"); + this.progressBar = this.shadowRoot.getElementById("progressBar"); + this.bufferBar = this.shadowRoot.getElementById("bufferBar"); + this.bufferA11yVal = this.shadowRoot.getElementById("bufferA11yVal"); + this.scrubberStack = this.shadowRoot.getElementById("scrubberStack"); + this.scrubber = this.shadowRoot.getElementById("scrubber"); + this.durationLabel = this.shadowRoot.getElementById("durationLabel"); + this.positionLabel = this.shadowRoot.getElementById("positionLabel"); + this.positionDurationBox = this.shadowRoot.getElementById( + "positionDurationBox" + ); + this.statusOverlay = this.shadowRoot.getElementById("statusOverlay"); + this.controlsOverlay = this.shadowRoot.getElementById( + "controlsOverlay" + ); + this.pictureInPictureOverlay = this.shadowRoot.getElementById( + "pictureInPictureOverlay" + ); + this.controlsSpacer = this.shadowRoot.getElementById("controlsSpacer"); + this.clickToPlay = this.shadowRoot.getElementById("clickToPlay"); + this.fullscreenButton = this.shadowRoot.getElementById( + "fullscreenButton" + ); + this.castingButton = this.shadowRoot.getElementById("castingButton"); + this.closedCaptionButton = this.shadowRoot.getElementById( + "closedCaptionButton" + ); + this.textTrackList = this.shadowRoot.getElementById("textTrackList"); + this.textTrackListContainer = this.shadowRoot.getElementById( + "textTrackListContainer" + ); + this.pictureInPictureToggle = this.shadowRoot.getElementById( + "pictureInPictureToggle" + ); + + if (this.positionDurationBox) { + this.durationSpan = this.positionDurationBox.getElementsByTagName( + "span" + )[0]; + } + + let isMobile = this.window.navigator.appVersion.includes("Android"); + if (isMobile) { + this.controlsContainer.classList.add("mobile"); + } + + // TODO: Switch to touch controls on touch-based desktops (bug 1447547) + this.isTouchControls = isMobile; + if (this.isTouchControls) { + this.controlsContainer.classList.add("touch"); + } + + // XXX: Calling getComputedStyle() here by itself doesn't cause any reflow, + // but there is no guard proventing accessing any properties and methods + // of this saved CSSStyleDeclaration instance that could trigger reflow. + this.controlBarComputedStyles = this.window.getComputedStyle( + this.controlBar + ); + + // Hide and show control in certain order. + this.prioritizedControls = [ + this.playButton, + this.muteButton, + this.fullscreenButton, + this.castingButton, + this.closedCaptionButton, + this.positionDurationBox, + this.scrubberStack, + this.durationSpan, + this.volumeStack, + ]; + + this.isAudioOnly = this.video.localName == "audio"; + this.setupInitialState(); + this.setupNewLoadState(); + this.initTextTracks(); + + // Use the handleEvent() callback for all media events. + // Only the "error" event listener must capture, so that it can trap error + // events from <source> children, which don't bubble. But we use capture + // for all events in order to simplify the event listener add/remove. + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } + + this.controlsEvents = [ + { el: this.muteButton, type: "click" }, + { el: this.castingButton, type: "click" }, + { el: this.closedCaptionButton, type: "click" }, + { el: this.fullscreenButton, type: "click" }, + { el: this.playButton, type: "click" }, + { el: this.clickToPlay, type: "click" }, + + // On touch videocontrols, tapping controlsSpacer should show/hide + // the control bar, instead of playing the video or toggle fullscreen. + { el: this.controlsSpacer, type: "click", nonTouchOnly: true }, + { el: this.controlsSpacer, type: "dblclick", nonTouchOnly: true }, + + { el: this.textTrackList, type: "click" }, + + { el: this.videocontrols, type: "resizevideocontrols" }, + + { el: this.document, type: "fullscreenchange" }, + { el: this.video, type: "keypress", capture: true }, + + // Prevent any click event within media controls from dispatching through to video. + { el: this.videocontrols, type: "click", mozSystemGroup: false }, + + // prevent dragging of controls image (bug 517114) + { el: this.videocontrols, type: "dragstart" }, + + { el: this.scrubber, type: "input" }, + { el: this.scrubber, type: "change" }, + // add mouseup listener additionally to handle the case that `change` event + // isn't fired when the input value before/after dragging are the same. (bug 1328061) + { el: this.scrubber, type: "mouseup" }, + { el: this.volumeControl, type: "input" }, + { el: this.video.textTracks, type: "addtrack" }, + { el: this.video.textTracks, type: "removetrack" }, + { el: this.video.textTracks, type: "change" }, + + { el: this.video, type: "media-videoCasting", touchOnly: true }, + + { el: this.controlBar, type: "focusin" }, + { el: this.scrubber, type: "mousedown" }, + { el: this.volumeControl, type: "mousedown" }, + ]; + + for (let { + el, + type, + nonTouchOnly = false, + touchOnly = false, + mozSystemGroup = true, + capture = false, + } of this.controlsEvents) { + if ( + (this.isTouchControls && nonTouchOnly) || + (!this.isTouchControls && touchOnly) + ) { + continue; + } + el.addEventListener(type, this, { mozSystemGroup, capture }); + } + + this.log("--- videocontrols initialized ---"); + }, + }; + + this.TouchUtils = { + videocontrols: null, + video: null, + controlsTimer: null, + controlsTimeout: 5000, + + get visible() { + return ( + !this.Utils.controlBar.hasAttribute("fadeout") && + !this.Utils.controlBar.hidden + ); + }, + + firstShow: false, + + toggleControls() { + if (!this.Utils.dynamicControls || !this.visible) { + this.showControls(); + } else { + this.delayHideControls(0); + } + }, + + showControls() { + if (this.Utils.dynamicControls) { + this.Utils.startFadeIn(this.Utils.controlBar); + this.delayHideControls(this.controlsTimeout); + } + }, + + clearTimer() { + if (this.controlsTimer) { + this.window.clearTimeout(this.controlsTimer); + this.controlsTimer = null; + } + }, + + delayHideControls(aTimeout) { + this.clearTimer(); + this.controlsTimer = this.window.setTimeout( + () => this.hideControls(), + aTimeout + ); + }, + + hideControls() { + if (!this.Utils.dynamicControls) { + return; + } + this.Utils.startFadeOut(this.Utils.controlBar); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "click": + switch (aEvent.currentTarget) { + case this.Utils.playButton: + if (!this.video.paused) { + this.delayHideControls(0); + } else { + this.showControls(); + } + break; + case this.Utils.muteButton: + this.delayHideControls(this.controlsTimeout); + break; + } + break; + case "touchstart": + this.clearTimer(); + break; + case "touchend": + this.delayHideControls(this.controlsTimeout); + break; + case "mouseup": + if (aEvent.originalTarget == this.Utils.controlsSpacer) { + if (this.firstShow) { + this.Utils.video.play(); + this.firstShow = false; + } + this.toggleControls(); + } + + break; + } + }, + + terminate() { + try { + for (let { el, type, mozSystemGroup = true } of this.controlsEvents) { + el.removeEventListener(type, this, { mozSystemGroup }); + } + } catch (ex) {} + + this.clearTimer(); + }, + + init(shadowRoot, utils) { + this.Utils = utils; + this.videocontrols = this.Utils.videocontrols; + this.video = this.Utils.video; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.controlsEvents = [ + { el: this.Utils.playButton, type: "click" }, + { el: this.Utils.scrubber, type: "touchstart" }, + { el: this.Utils.scrubber, type: "touchend" }, + { el: this.Utils.muteButton, type: "click" }, + { el: this.Utils.controlsSpacer, type: "mouseup" }, + ]; + + for (let { el, type, mozSystemGroup = true } of this.controlsEvents) { + el.addEventListener(type, this, { mozSystemGroup }); + } + + // The first time the controls appear we want to just display + // a play button that does not fade away. The firstShow property + // makes that happen. But because of bug 718107 this init() method + // may be called again when we switch in or out of fullscreen + // mode. So we only set firstShow if we're not autoplaying and + // if we are at the beginning of the video and not already playing + if ( + !this.video.autoplay && + this.Utils.dynamicControls && + this.video.paused && + this.video.currentTime === 0 + ) { + this.firstShow = true; + } + + // If the video is not at the start, then we probably just + // transitioned into or out of fullscreen mode, and we don't want + // the controls to remain visible. this.controlsTimeout is a full + // 5s, which feels too long after the transition. + if (this.video.currentTime !== 0) { + this.delayHideControls(this.Utils.HIDE_CONTROLS_TIMEOUT_MS); + } + }, + }; + + this.Utils.init(this.shadowRoot, this.prefs); + if (this.Utils.isTouchControls) { + this.TouchUtils.init(this.shadowRoot, this.Utils); + } + this.shadowRoot.firstChild.dispatchEvent( + new this.window.CustomEvent("VideoBindingAttached") + ); + + this._setupEventListeners(); + } + + generateContent() { + /* + * Pass the markup through XML parser purely for the reason of loading the localization DTD. + * Remove it when migrate to Fluent. + */ + const parser = new this.window.DOMParser(); + parser.forceEnableDTD(); + let parserDoc = parser.parseFromString( + `<!DOCTYPE bindings [ + <!ENTITY % videocontrolsDTD SYSTEM "chrome://global/locale/videocontrols.dtd"> + %videocontrolsDTD; + ]> + <div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + + <div id="controlsContainer" class="controlsContainer" role="none"> + <div id="statusOverlay" class="statusOverlay stackItem" hidden="true"> + <div id="statusIcon" class="statusIcon"></div> + <bdi class="statusLabel" id="errorAborted">&error.aborted;</bdi> + <bdi class="statusLabel" id="errorNetwork">&error.network;</bdi> + <bdi class="statusLabel" id="errorDecode">&error.decode;</bdi> + <bdi class="statusLabel" id="errorSrcNotSupported">&error.srcNotSupported;</bdi> + <bdi class="statusLabel" id="errorNoSource">&error.noSource2;</bdi> + <bdi class="statusLabel" id="errorGeneric">&error.generic;</bdi> + </div> + + <div id="pictureInPictureOverlay" class="pictureInPictureOverlay stackItem" status="pictureInPicture" hidden="true"> + <div class="statusIcon" type="pictureInPicture"></div> + <bdi class="statusLabel" id="pictureInPicture">&status.pictureInPicture;</bdi> + </div> + + <div id="controlsOverlay" class="controlsOverlay stackItem" role="none"> + <div class="controlsSpacerStack"> + <div id="controlsSpacer" class="controlsSpacer stackItem" role="none"></div> + <div id="clickToPlay" class="clickToPlay" hidden="true"></div> + </div> + + <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true"> + <div class="pip-small clickable"></div> + <div class="pip-expanded clickable"> + <span class="pip-icon-label clickable"> + <span class="pip-icon"></span> + <span class="pip-label">&pictureInPictureToggle.label;</span> + </span> + <div class="pip-explainer clickable"> + &pictureInPictureExplainer; + </div> + </div> + <div class="pip-icon clickable"></div> + </button> + + <div id="controlBar" class="controlBar" role="none" hidden="true"> + <button id="playButton" + class="button playButton" + playlabel="&playButton.playLabel;" + pauselabel="&playButton.pauseLabel;" + tabindex="-1"/> + <div id="scrubberStack" class="scrubberStack progressContainer" role="none"> + <div class="progressBackgroundBar stackItem" role="none"> + <div class="progressStack" role="none"> + <progress id="bufferBar" class="bufferBar" value="0" max="100" aria-hidden="true"></progress> + <span class="a11y-only" role="status" aria-live="off"> + <span data-l10n-id="videocontrols-buffer-bar-label"></span> + <span id="bufferA11yVal"></span> + </span> + <progress id="progressBar" class="progressBar" value="0" max="100" aria-hidden="true"></progress> + </div> + </div> + <input type="range" id="scrubber" class="scrubber" tabindex="-1" data-l10n-id="videocontrols-scrubber"/> + </div> + <bdi id="positionLabel" class="positionLabel" role="presentation"></bdi> + <bdi id="durationLabel" class="durationLabel" role="presentation"></bdi> + <bdi id="positionDurationBox" class="positionDurationBox" aria-hidden="true"> + &positionAndDuration.nameFormat; + </bdi> + <div id="controlBarSpacer" class="controlBarSpacer" hidden="true" role="none"></div> + <button id="muteButton" + class="button muteButton" + mutelabel="&muteButton.muteLabel;" + unmutelabel="&muteButton.unmuteLabel;" + tabindex="-1"/> + <div id="volumeStack" class="volumeStack progressContainer" role="none"> + <input type="range" id="volumeControl" class="volumeControl" min="0" max="100" step="1" tabindex="-1" + data-l10n-id="videocontrols-volume-control"/> + </div> + <button id="castingButton" class="button castingButton" + aria-label="&castingButton.castingLabel;"/> + <button id="closedCaptionButton" class="button closedCaptionButton" + data-l10n-id="videocontrols-closed-caption-button"/> + <button id="fullscreenButton" + class="button fullscreenButton" + enterfullscreenlabel="&fullscreenButton.enterfullscreenlabel;" + exitfullscreenlabel="&fullscreenButton.exitfullscreenlabel;"/> + </div> + <div id="textTrackListContainer" class="textTrackListContainer" hidden="true"> + <div id="textTrackList" class="textTrackList" offlabel="&closedCaption.off;"></div> + </div> + </div> + </div> + </div>`, + "application/xml" + ); + this.l10n = new this.window.DOMLocalization([ + "toolkit/global/videocontrols.ftl", + ]); + this.l10n.connectRoot(this.shadowRoot); + if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) { + // Make all of the individual controls tabbable. + for (const el of parserDoc.documentElement.querySelectorAll( + '[tabindex="-1"]' + )) { + el.removeAttribute("tabindex"); + } + } + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } + + elementStateMatches(element) { + let elementInPiP = VideoControlsWidget.isPictureInPictureVideo(element); + return this.isShowingPictureInPictureMessage == elementInPiP; + } + + destructor() { + this.Utils.terminate(); + this.TouchUtils.terminate(); + this.Utils.updateOrientationState(false); + this.l10n.disconnectRoot(this.shadowRoot); + this.l10n = null; + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + this.Utils.updatePictureInPictureToggleDisplay(); + } + + _setupEventListeners() { + this.shadowRoot.firstChild.addEventListener("mouseover", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseInOut(event); + } + }); + + this.shadowRoot.firstChild.addEventListener("mouseout", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseInOut(event); + } + }); + + this.shadowRoot.firstChild.addEventListener("mousemove", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseMove(event); + } + }); + } +}; + +this.NoControlsMobileImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + videoEvents: ["play", "playing"], + videoControlEvents: ["MozNoControlsBlockedVideo"], + terminate() { + for (let event of this.videoEvents) { + try { + this.video.removeEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } catch (ex) {} + } + + for (let event of this.videoControlEvents) { + try { + this.videocontrols.removeEventListener(event, this); + } catch (ex) {} + } + + try { + this.clickToPlay.removeEventListener("click", this, { + mozSystemGroup: true, + }); + } catch (ex) {} + }, + + hasError() { + return ( + this.video.error != null || + this.video.networkState == this.video.NETWORK_NO_SOURCE + ); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "play": + this.noControlsOverlay.hidden = true; + break; + case "playing": + this.noControlsOverlay.hidden = true; + break; + case "MozNoControlsBlockedVideo": + this.blockedVideoHandler(); + break; + case "click": + this.clickToPlayClickHandler(aEvent); + break; + } + }, + + blockedVideoHandler() { + if (this.hasError()) { + this.noControlsOverlay.hidden = true; + return; + } + this.noControlsOverlay.hidden = false; + }, + + clickToPlayClickHandler(e) { + if (e.button != 0) { + return; + } + + this.noControlsOverlay.hidden = true; + this.video.play(); + }, + + init(shadowRoot) { + this.shadowRoot = shadowRoot; + this.video = shadowRoot.host; + this.videocontrols = shadowRoot.firstChild; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.controlsContainer = this.shadowRoot.getElementById( + "controlsContainer" + ); + this.clickToPlay = this.shadowRoot.getElementById("clickToPlay"); + this.noControlsOverlay = this.shadowRoot.getElementById( + "controlsContainer" + ); + + let isMobile = this.window.navigator.appVersion.includes("Android"); + if (isMobile) { + this.controlsContainer.classList.add("mobile"); + } + + // TODO: Switch to touch controls on touch-based desktops (bug 1447547) + this.isTouchControls = isMobile; + if (this.isTouchControls) { + this.controlsContainer.classList.add("touch"); + } + + this.clickToPlay.addEventListener("click", this, { + mozSystemGroup: true, + }); + + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } + + for (let event of this.videoControlEvents) { + this.videocontrols.addEventListener(event, this); + } + }, + }; + this.Utils.init(this.shadowRoot); + this.Utils.video.dispatchEvent( + new this.window.CustomEvent("MozNoControlsVideoBindingAttached") + ); + } + + elementStateMatches(element) { + return true; + } + + destructor() { + this.Utils.terminate(); + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + } + + generateContent() { + /* + * Pass the markup through XML parser purely for the reason of loading the localization DTD. + * Remove it when migrate to Fluent. + */ + const parser = new this.window.DOMParser(); + parser.forceEnableDTD(); + let parserDoc = parser.parseFromString( + `<!DOCTYPE bindings [ + <!ENTITY % videocontrolsDTD SYSTEM "chrome://global/locale/videocontrols.dtd"> + %videocontrolsDTD; + ]> + <div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <div id="controlsContainer" class="controlsContainer" role="none" hidden="true"> + <div class="controlsOverlay stackItem"> + <div class="controlsSpacerStack"> + <div id="clickToPlay" class="clickToPlay"></div> + </div> + </div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } +}; + +this.NoControlsPictureInPictureImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + } + + elementStateMatches(element) { + return true; + } + + destructor() {} + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + } + + generateContent() { + /* + * Pass the markup through XML parser purely for the reason of loading the localization DTD. + * Remove it when migrate to Fluent. + */ + const parser = new this.window.DOMParser(); + parser.forceEnableDTD(); + let parserDoc = parser.parseFromString( + `<!DOCTYPE bindings [ + <!ENTITY % videocontrolsDTD SYSTEM "chrome://global/locale/videocontrols.dtd"> + %videocontrolsDTD; + ]> + <div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <div id="controlsContainer" class="controlsContainer" role="none"> + <div class="pictureInPictureOverlay stackItem" status="pictureInPicture"> + <div id="statusIcon" class="statusIcon" type="pictureInPicture"></div> + <bdi class="statusLabel" id="pictureInPicture">&status.pictureInPicture;</bdi> + </div> + <div class="controlsOverlay stackItem"></div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } +}; + +this.NoControlsDesktopImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + this.prefs = prefs; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + handleEvent(event) { + switch (event.type) { + case "fullscreenchange": { + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } else { + this.videocontrols.removeAttribute("inDOMFullscreen"); + } + break; + } + case "resizevideocontrols": { + this.updateReflowedDimensions(); + this.updatePictureInPictureToggleDisplay(); + break; + } + case "durationchange": + // Intentional fall-through + case "emptied": + // Intentional fall-through + case "loadedmetadata": { + this.updatePictureInPictureToggleDisplay(); + break; + } + } + }, + + updatePictureInPictureToggleDisplay() { + if ( + this.pipToggleEnabled && + VideoControlsWidget.shouldShowPictureInPictureToggle( + this.prefs, + this.video, + this.reflowedDimensions + ) + ) { + this.pictureInPictureToggle.removeAttribute("hidden"); + VideoControlsWidget.setupToggle( + this.prefs, + this.pictureInPictureToggle, + this.reflowedDimensions + ); + } else { + this.pictureInPictureToggle.setAttribute("hidden", true); + } + }, + + init(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.video = shadowRoot.host; + this.videocontrols = shadowRoot.firstChild; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.pictureInPictureToggle = this.shadowRoot.getElementById( + "pictureInPictureToggle" + ); + + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } + + // Default the Picture-in-Picture toggle button to being hidden. We might unhide it + // later if we determine that this video is qualified to show it. + this.pictureInPictureToggle.setAttribute("hidden", true); + + if (this.video.readyState >= this.video.HAVE_METADATA) { + // According to the spec[1], at the HAVE_METADATA (or later) state, we know + // the video duration and dimensions, which means we can calculate whether or + // not to show the Picture-in-Picture toggle now. + // + // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata + this.updatePictureInPictureToggleDisplay(); + } + + this.document.addEventListener("fullscreenchange", this, { + capture: true, + }); + + this.video.addEventListener("emptied", this); + this.video.addEventListener("loadedmetadata", this); + this.video.addEventListener("durationchange", this); + this.videocontrols.addEventListener("resizevideocontrols", this); + }, + + terminate() { + this.document.removeEventListener("fullscreenchange", this, { + capture: true, + }); + + this.video.removeEventListener("emptied", this); + this.video.removeEventListener("loadedmetadata", this); + this.video.removeEventListener("durationchange", this); + this.videocontrols.removeEventListener("resizevideocontrols", this); + }, + + updateReflowedDimensions() { + this.reflowedDimensions.videoHeight = this.video.clientHeight; + this.reflowedDimensions.videoWidth = this.video.clientWidth; + this.reflowedDimensions.videocontrolsWidth = this.videocontrols.clientWidth; + }, + + reflowedDimensions: { + // Set the dimensions to intrinsic <video> dimensions before the first + // update. + videoHeight: 150, + videoWidth: 300, + videocontrolsWidth: 0, + }, + + get pipToggleEnabled() { + return this.prefs[ + "media.videocontrols.picture-in-picture.video-toggle.enabled" + ]; + }, + }; + this.Utils.init(this.shadowRoot, this.prefs); + } + + elementStateMatches(element) { + return true; + } + + destructor() { + this.Utils.terminate(); + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + this.Utils.updatePictureInPictureToggleDisplay(); + } + + generateContent() { + /* + * Pass the markup through XML parser purely for the reason of loading the localization DTD. + * Remove it when migrate to Fluent. + */ + const parser = new this.window.DOMParser(); + parser.forceEnableDTD(); + let parserDoc = parser.parseFromString( + `<!DOCTYPE bindings [ + <!ENTITY % videocontrolsDTD SYSTEM "chrome://global/locale/videocontrols.dtd"> + %videocontrolsDTD; + ]> + <div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + + <div id="controlsContainer" class="controlsContainer" role="none"> + <div class="controlsOverlay stackItem"> + <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true"> + <div class="pip-small clickable"></div> + <div class="pip-expanded clickable"> + <span class="pip-icon-label clickable"> + <span class="pip-icon"></span> + <span class="pip-label">&pictureInPictureToggle.label;</span> + </span> + <div class="pip-explainer clickable"> + &pictureInPictureExplainer; + </div> + </div> + <div class="pip-icon"></div> + </button> + </div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } +}; diff --git a/toolkit/content/widgets/wizard.js b/toolkit/content/widgets/wizard.js new file mode 100644 index 0000000000..a029d29f3b --- /dev/null +++ b/toolkit/content/widgets/wizard.js @@ -0,0 +1,668 @@ +/* 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 is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + // Note: MozWizard currently supports adding, but not removing MozWizardPage + // children. + class MozWizard extends MozXULElement { + constructor() { + super(); + + // About this._accessMethod: + // There are two possible access methods: "sequential" and "random". + // "sequential" causes the MozWizardPage's to be displayed in the order + // that they are added to the DOM. + // The "random" method name is a bit misleading since the pages aren't + // displayed in a random order. Instead, each MozWizardPage must have + // a "next" attribute containing the id of the MozWizardPage that should + // be loaded next. + this._accessMethod = null; + this._currentPage = null; + this._canAdvance = true; + this._canRewind = false; + this._hasLoaded = false; + this._hasStarted = false; // Whether any MozWizardPage has been shown yet + this._wizardButtonsReady = false; + this.pageCount = 0; + this._pageStack = []; + + this._bundle = Services.strings.createBundle( + "chrome://global/locale/wizard.properties" + ); + + this.addEventListener( + "keypress", + event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + this._hitEnter(event); + } else if ( + event.keyCode == KeyEvent.DOM_VK_ESCAPE && + !event.defaultPrevented + ) { + this.cancel(); + } + }, + { mozSystemGroup: true } + ); + + /* + XXX(ntim): We import button.css here for the wizard-buttons children + This won't be needed after bug 1624888. + */ + this.attachShadow({ mode: "open" }).appendChild( + MozXULElement.parseXULToFragment(` + <html:link rel="stylesheet" href="chrome://global/skin/button.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/wizard.css"/> + <hbox class="wizard-header"></hbox> + <deck class="wizard-page-box" flex="1"> + <html:slot name="wizardpage"/> + </deck> + <html:slot/> + <wizard-buttons class="wizard-buttons"></wizard-buttons> + `) + ); + this.initializeAttributeInheritance(); + + this._deck = this.shadowRoot.querySelector(".wizard-page-box"); + this._wizardButtons = this.shadowRoot.querySelector(".wizard-buttons"); + + this._wizardHeader = this.shadowRoot.querySelector(".wizard-header"); + this._wizardHeader.appendChild( + MozXULElement.parseXULToFragment( + AppConstants.platform == "macosx" + ? `<stack class="wizard-header-stack" flex="1"> + <vbox class="wizard-header-box-1"> + <vbox class="wizard-header-box-text"> + <label class="wizard-header-label"/> + </vbox> + </vbox> + <hbox class="wizard-header-box-icon"> + <spacer flex="1"/> + <image class="wizard-header-icon"/> + </hbox> + </stack>` + : `<hbox class="wizard-header-box-1" flex="1"> + <vbox class="wizard-header-box-text" flex="1"> + <label class="wizard-header-label"/> + <label class="wizard-header-description"/> + </vbox> + <image class="wizard-header-icon"/> + </hbox>` + ) + ); + } + + static get inheritedAttributes() { + return { + ".wizard-buttons": "pagestep,firstpage,lastpage", + }; + } + + connectedCallback() { + if (document.l10n) { + document.l10n.connectRoot(this.shadowRoot); + } + document.documentElement.setAttribute("role", "dialog"); + this._maybeStartWizard(); + + window.addEventListener("close", event => { + if (this.cancel()) { + event.preventDefault(); + } + }); + + // Give focus to the first focusable element in the wizard, do it after + // onload completes, see bug 103197. + window.addEventListener("load", () => + window.setTimeout(() => { + this._hasLoaded = true; + if (!document.commandDispatcher.focusedElement) { + document.commandDispatcher.advanceFocusIntoSubtree(this); + } + try { + let button = this._wizardButtons.defaultButton; + if (button) { + window.notifyDefaultButtonLoaded(button); + } + } catch (e) {} + }, 0) + ); + } + + set title(val) { + return (document.title = val); + } + + get title() { + return document.title; + } + + set canAdvance(val) { + this.getButton("next").disabled = !val; + return (this._canAdvance = val); + } + + get canAdvance() { + return this._canAdvance; + } + + set canRewind(val) { + this.getButton("back").disabled = !val; + return (this._canRewind = val); + } + + get canRewind() { + return this._canRewind; + } + + get pageStep() { + return this._pageStack.length; + } + + get wizardPages() { + return this.getElementsByTagNameNS(XUL_NS, "wizardpage"); + } + + set currentPage(val) { + if (!val) { + return val; + } + + this._currentPage = val; + + // Setting this attribute allows wizard's clients to dynamically + // change the styles of each page based on purpose of the page. + this.setAttribute("currentpageid", val.pageid); + + this._initCurrentPage(); + + this._deck.setAttribute("selectedIndex", val.pageIndex); + this._advanceFocusToPage(val); + + this._fireEvent(val, "pageshow"); + + return val; + } + + get currentPage() { + return this._currentPage; + } + + set pageIndex(val) { + if (val < 0 || val >= this.pageCount) { + return val; + } + + var page = this.wizardPages[val]; + this._pageStack[this._pageStack.length - 1] = page; + this.currentPage = page; + + return val; + } + + get pageIndex() { + return this._currentPage ? this._currentPage.pageIndex : -1; + } + + get onFirstPage() { + return this._pageStack.length == 1; + } + + get onLastPage() { + var cp = this.currentPage; + return ( + cp && + ((this._accessMethod == "sequential" && + cp.pageIndex == this.pageCount - 1) || + (this._accessMethod == "random" && cp.next == "")) + ); + } + + getButton(aDlgType) { + return this._wizardButtons.getButton(aDlgType); + } + + getPageById(aPageId) { + var els = this.getElementsByAttribute("pageid", aPageId); + return els.item(0); + } + + extra1() { + if (this.currentPage) { + this._fireEvent(this.currentPage, "extra1"); + } + } + + extra2() { + if (this.currentPage) { + this._fireEvent(this.currentPage, "extra2"); + } + } + + rewind() { + if (!this.canRewind) { + return; + } + + if (this.currentPage && !this._fireEvent(this.currentPage, "pagehide")) { + return; + } + + if ( + this.currentPage && + !this._fireEvent(this.currentPage, "pagerewound") + ) { + return; + } + + if (!this._fireEvent(this, "wizardback")) { + return; + } + + this._pageStack.pop(); + this.currentPage = this._pageStack[this._pageStack.length - 1]; + this.setAttribute("pagestep", this._pageStack.length); + } + + advance(aPageId) { + if (!this.canAdvance) { + return; + } + + if (this.currentPage && !this._fireEvent(this.currentPage, "pagehide")) { + return; + } + + if ( + this.currentPage && + !this._fireEvent(this.currentPage, "pageadvanced") + ) { + return; + } + + if (this.onLastPage && !aPageId) { + if (this._fireEvent(this, "wizardfinish")) { + window.setTimeout(function() { + window.close(); + }, 1); + } + } else { + if (!this._fireEvent(this, "wizardnext")) { + return; + } + + let page; + if (aPageId) { + page = this.getPageById(aPageId); + } else if (this.currentPage) { + if (this._accessMethod == "random") { + page = this.getPageById(this.currentPage.next); + } else { + page = this.wizardPages[this.currentPage.pageIndex + 1]; + } + } else { + page = this.wizardPages[0]; + } + + if (page) { + this._pageStack.push(page); + this.setAttribute("pagestep", this._pageStack.length); + + this.currentPage = page; + } + } + } + + goTo(aPageId) { + var page = this.getPageById(aPageId); + if (page) { + this._pageStack[this._pageStack.length - 1] = page; + this.currentPage = page; + } + } + + cancel() { + if (!this._fireEvent(this, "wizardcancel")) { + return true; + } + + window.close(); + window.setTimeout(function() { + window.close(); + }, 1); + return false; + } + + _initCurrentPage() { + if (this.onFirstPage) { + this.canRewind = false; + this.setAttribute("firstpage", "true"); + if (AppConstants.platform == "linux") { + this.getButton("back").setAttribute("hidden", "true"); + } + } else { + this.canRewind = true; + this.setAttribute("firstpage", "false"); + if (AppConstants.platform == "linux") { + this.getButton("back").setAttribute("hidden", "false"); + } + } + + if (this.onLastPage) { + this.canAdvance = true; + this.setAttribute("lastpage", "true"); + } else { + this.setAttribute("lastpage", "false"); + } + + this._adjustWizardHeader(); + this._wizardButtons.onPageChange(); + } + + _advanceFocusToPage(aPage) { + if (!this._hasLoaded) { + return; + } + + // XXX: it'd be correct to advance focus into the panel, however we can't do + // it until bug 1558990 is fixed, so moving the focus into a wizard itsef + // as a workaround - it's same behavior but less optimal. + document.commandDispatcher.advanceFocusIntoSubtree(this); + + // if advanceFocusIntoSubtree tries to focus one of our + // dialog buttons, then remove it and put it on the root + var focused = document.commandDispatcher.focusedElement; + if (focused && focused.hasAttribute("dlgtype")) { + this.focus(); + } + } + + _registerPage(aPage) { + aPage.pageIndex = this.pageCount; + this.pageCount += 1; + if (!this._accessMethod) { + this._accessMethod = aPage.next == "" ? "sequential" : "random"; + } + if (!this._maybeStartWizard() && this._hasStarted) { + // If the wizard has already started, adding a page might require + // updating elements to reflect that (ex: changing the Finish button to + // the Next button). + this._initCurrentPage(); + } + } + + _onWizardButtonsReady() { + this._wizardButtonsReady = true; + this._maybeStartWizard(); + } + + _maybeStartWizard(aIsConnected) { + if ( + !this._hasStarted && + this.isConnected && + this._wizardButtonsReady && + this.pageCount > 0 + ) { + this._hasStarted = true; + this.advance(); + return true; + } + return false; + } + + _adjustWizardHeader() { + let labelElement = this._wizardHeader.querySelector( + ".wizard-header-label" + ); + // First deal with fluent. Ideally, we'd stop supporting anything else, + // but right now the migration wizard still uses non-fluent l10n + // (fixing is bug 1518234), as do some comm-central consumers + // (bug 1627049). Removing the DTD support is bug 1627051. + if (this.currentPage.hasAttribute("data-header-label-id")) { + let id = this.currentPage.getAttribute("data-header-label-id"); + document.l10n.setAttributes(labelElement, id); + } else { + // Otherwise, make sure we remove any fluent IDs leftover: + if (labelElement.hasAttribute("data-l10n-id")) { + labelElement.removeAttribute("data-l10n-id"); + } + // And use the label attribute or the default: + var label = this.currentPage.getAttribute("label") || ""; + if (!label && this.onFirstPage && this._bundle) { + if (AppConstants.platform == "macosx") { + label = this._bundle.GetStringFromName("default-first-title-mac"); + } else { + label = this._bundle.formatStringFromName("default-first-title", [ + this.title, + ]); + } + } else if (!label && this.onLastPage && this._bundle) { + if (AppConstants.platform == "macosx") { + label = this._bundle.GetStringFromName("default-last-title-mac"); + } else { + label = this._bundle.formatStringFromName("default-last-title", [ + this.title, + ]); + } + } + labelElement.textContent = label; + } + let headerDescEl = this._wizardHeader.querySelector( + ".wizard-header-description" + ); + if (headerDescEl) { + headerDescEl.textContent = this.currentPage.getAttribute("description"); + } + } + + _hitEnter(evt) { + if (!evt.defaultPrevented) { + this.advance(); + } + } + + _fireEvent(aTarget, aType) { + var event = document.createEvent("Events"); + event.initEvent(aType, true, true); + + // handle dom event handlers + return aTarget.dispatchEvent(event); + } + } + + customElements.define("wizard", MozWizard); + + class MozWizardPage extends MozXULElement { + constructor() { + super(); + this.pageIndex = -1; + } + connectedCallback() { + this.setAttribute("slot", "wizardpage"); + + let wizard = this.closest("wizard"); + if (wizard) { + wizard._registerPage(this); + } + } + get pageid() { + return this.getAttribute("pageid"); + } + set pageid(val) { + this.setAttribute("pageid", val); + } + get next() { + return this.getAttribute("next"); + } + set next(val) { + this.setAttribute("next", val); + this.parentNode._accessMethod = "random"; + return val; + } + } + + customElements.define("wizardpage", MozWizardPage); + + class MozWizardButtons extends MozXULElement { + connectedCallback() { + this._wizard = this.getRootNode().host; + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + MozXULElement.insertFTLIfNeeded("toolkit/global/wizard.ftl"); + + this._wizardButtonDeck = this.querySelector(".wizard-next-deck"); + + this.initializeAttributeInheritance(); + + const listeners = [ + ["back", () => this._wizard.rewind()], + ["next", () => this._wizard.advance()], + ["finish", () => this._wizard.advance()], + ["cancel", () => this._wizard.cancel()], + ["extra1", () => this._wizard.extra1()], + ["extra2", () => this._wizard.extra2()], + ]; + for (let [name, listener] of listeners) { + let btn = this.getButton(name); + if (btn) { + btn.addEventListener("command", listener); + } + } + + this._wizard._onWizardButtonsReady(); + } + + static get inheritedAttributes() { + return AppConstants.platform == "macosx" + ? { + "[dlgtype='next']": "hidden=lastpage", + } + : null; + } + + static get markup() { + if (AppConstants.platform == "macosx") { + return ` + <vbox flex="1"> + <hbox class="wizard-buttons-btm"> + <button class="wizard-button" dlgtype="extra1" hidden="true"/> + <button class="wizard-button" dlgtype="extra2" hidden="true"/> + <button data-l10n-id="wizard-macos-button-cancel" + class="wizard-button" dlgtype="cancel"/> + <spacer flex="1"/> + <button data-l10n-id="wizard-macos-button-back" + class="wizard-button wizard-nav-button" dlgtype="back"/> + <button data-l10n-id="wizard-macos-button-next" + class="wizard-button wizard-nav-button" dlgtype="next" + default="true" /> + <button data-l10n-id="wizard-macos-button-finish" class="wizard-button" + dlgtype="finish" default="true" /> + </hbox> + </vbox>`; + } + + let buttons = + AppConstants.platform == "linux" + ? ` + <button data-l10n-id="wizard-linux-button-cancel" + class="wizard-button" + dlgtype="cancel"/> + <spacer style="width: 24px;"/> + <button data-l10n-id="wizard-linux-button-back" + class="wizard-button" dlgtype="back"/> + <deck class="wizard-next-deck"> + <hbox> + <button data-l10n-id="wizard-linux-button-finish" + class="wizard-button" + dlgtype="finish" default="true" flex="1"/> + </hbox> + <hbox> + <button data-l10n-id="wizard-linux-button-next" + class="wizard-button" dlgtype="next" + default="true" flex="1"/> + </hbox> + </deck>` + : ` + <button data-l10n-id="wizard-win-button-back" + class="wizard-button" dlgtype="back"/> + <deck class="wizard-next-deck"> + <hbox> + <button data-l10n-id="wizard-win-button-finish" + class="wizard-button" + dlgtype="finish" default="true" flex="1"/> + </hbox> + <hbox> + <button data-l10n-id="wizard-win-button-next" + class="wizard-button" dlgtype="next" + default="true" flex="1"/> + </hbox> + </deck> + <button data-l10n-id="wizard-win-button-cancel" + class="wizard-button" + dlgtype="cancel"/>`; + + return ` + <vbox class="wizard-buttons-box-1" flex="1"> + <separator class="wizard-buttons-separator groove"/> + <hbox class="wizard-buttons-box-2"> + <button class="wizard-button" dlgtype="extra1" hidden="true"/> + <button class="wizard-button" dlgtype="extra2" hidden="true"/> + <spacer flex="1" anonid="spacer"/> + ${buttons} + </hbox> + </vbox>`; + } + + onPageChange() { + if (AppConstants.platform == "macosx") { + this.getButton("finish").hidden = !( + this.getAttribute("lastpage") == "true" + ); + } else if (this.getAttribute("lastpage") == "true") { + this._wizardButtonDeck.setAttribute("selectedIndex", 0); + } else { + this._wizardButtonDeck.setAttribute("selectedIndex", 1); + } + } + + getButton(type) { + return this.querySelector(`[dlgtype="${type}"]`); + } + + get defaultButton() { + let buttons = this._wizardButtonDeck.selectedPanel.getElementsByTagNameNS( + XUL_NS, + "button" + ); + for (let i = 0; i < buttons.length; i++) { + if ( + buttons[i].getAttribute("default") == "true" && + !buttons[i].hidden && + !buttons[i].disabled + ) { + return buttons[i]; + } + } + return null; + } + } + + customElements.define("wizard-buttons", MozWizardButtons); +} |