diff options
Diffstat (limited to 'toolkit/content/widgets/arrowscrollbox.js')
-rw-r--r-- | toolkit/content/widgets/arrowscrollbox.js | 868 |
1 files changed, 868 insertions, 0 deletions
diff --git a/toolkit/content/widgets/arrowscrollbox.js b/toolkit/content/widgets/arrowscrollbox.js new file mode 100644 index 0000000000..b4e421f6ff --- /dev/null +++ b/toolkit/content/widgets/arrowscrollbox.js @@ -0,0 +1,868 @@ +/* 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 ` + <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); + } + + 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); +} |