/* 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 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 ` `; } 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); } 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 average width 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 isAtEdge = (element, start) => { let edge = start ? this.startEndProps[0] : this.startEndProps[1]; let scrollEdge = this._boundsWithoutFlushing(this.scrollbox)[ edge ]; let elementEdge = this._boundsWithoutFlushing(element)[edge]; // This is enough slop (>2/3) so that no subpixel value should // get us confused about whether we reached the end. const EPSILON = 0.7; if (start) { return scrollEdge <= elementEdge + EPSILON; } return elementEdge <= scrollEdge + EPSILON; }; let elements = this._getScrollableElements(); let [startElement, endElement] = [ elements[0], elements[elements.length - 1], ]; if (this.isRTLScrollbox) { [startElement, endElement] = [endElement, startElement]; } scrolledToStart = startElement && isAtEdge(startElement, /* start = */ true); scrolledToEnd = endElement && isAtEdge(endElement, /* start = */ false); if (this.isRTLScrollbox) { [scrolledToStart, scrolledToEnd] = [ scrolledToEnd, scrolledToStart, ]; } } 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; } const { deltaMode } = event; let doScroll = false; let instant; let scrollAmount = 0; if (this.getAttribute("orient") == "vertical") { doScroll = true; scrollAmount = event.deltaY; } 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; scrollAmount = scrollByDelta; if (deltaMode == event.DOM_DELTA_PIXEL) { instant = true; } } if (this._prevMouseScrolls.length > 1) { this._prevMouseScrolls.shift(); } this._prevMouseScrolls.push(isVertical); } if (doScroll) { let direction = scrollAmount < 0 ? -1 : 1; if (deltaMode == event.DOM_DELTA_PAGE) { scrollAmount *= this.scrollClientSize; } else if (deltaMode == event.DOM_DELTA_LINE) { // Try to not scroll by more than one page when using line scrolling, // so that all elements are scrollable. let lineAmount = this.lineScrollAmount; let clientSize = this.scrollClientSize; if (Math.abs(scrollAmount * lineAmount) > clientSize) { // NOTE: This still tries to scroll a non-fractional amount of // items per line scrolled. scrollAmount = Math.max(1, Math.floor(clientSize / lineAmount)) * direction; } scrollAmount *= lineAmount; } else { // DOM_DELTA_PIXEL, leave scrollAmount untouched. } 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); }