/* 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");
MozXULElement.insertFTLIfNeeded("toolkit/global/arrowscrollbox.ftl");
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;
document.l10n.connectRoot(this.shadowRoot);
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;
}
document.l10n.disconnectRoot(this.shadowRoot);
}
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() {
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() {
this._isScrolling = true;
this._updateScrollButtonsDisabledState();
}
on_scrollend() {
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);
}