Source code

Revision control

Copy as Markdown

Other Tools

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
});
export class AutoScrollChild extends JSWindowActorChild {
constructor() {
super();
this._scrollable = null;
this._scrolldir = "";
this._startX = null;
this._startY = null;
this._screenX = null;
this._screenY = null;
this._lastFrame = null;
this._autoscrollHandledByApz = false;
this._scrollId = null;
this.observer = new AutoScrollObserver(this);
this.autoscrollLoop = this.autoscrollLoop.bind(this);
}
isAutoscrollBlocker(event) {
let mmPaste = Services.prefs.getBoolPref("middlemouse.paste");
let mmScrollbarPosition = Services.prefs.getBoolPref(
"middlemouse.scrollbarPosition"
);
let node = event.originalTarget;
let content = node.ownerGlobal;
// If the node is in editable document or content, we don't want to start
// autoscroll.
if (mmPaste) {
if (node.ownerDocument?.designMode == "on") {
return true;
}
const element =
node.nodeType === content.Node.ELEMENT_NODE ? node : node.parentElement;
if (element.isContentEditable) {
return true;
}
}
// Don't start if we're on a link.
let [href] = lazy.BrowserUtils.hrefAndLinkNodeForClickEvent(event);
if (href) {
return true;
}
// Or if we're pasting into an input field of sorts.
let closestInput = mmPaste && node.closest("input,textarea");
if (
content.HTMLInputElement.isInstance(closestInput) ||
content.HTMLTextAreaElement.isInstance(closestInput)
) {
return true;
}
// Or if we're on a scrollbar or XUL <tree>
if (
(mmScrollbarPosition &&
content.XULElement.isInstance(
node.closest("scrollbar,scrollcorner")
)) ||
content.XULElement.isInstance(node.closest("treechildren"))
) {
return true;
}
return false;
}
isScrollableElement(aNode) {
let content = aNode.ownerGlobal;
if (content.HTMLElement.isInstance(aNode)) {
return !content.HTMLSelectElement.isInstance(aNode) || aNode.multiple;
}
return content.XULElement.isInstance(aNode);
}
computeWindowScrollDirection(global) {
if (!global.scrollbars.visible) {
return null;
}
if (global.scrollMaxX != global.scrollMinX) {
return global.scrollMaxY != global.scrollMinY ? "NSEW" : "EW";
}
if (global.scrollMaxY != global.scrollMinY) {
return "NS";
}
return null;
}
computeNodeScrollDirection(node) {
if (!this.isScrollableElement(node)) {
return null;
}
let global = node.ownerGlobal;
// this is a list of overflow property values that allow scrolling
const scrollingAllowed = ["scroll", "auto"];
let cs = global.getComputedStyle(node);
let overflowx = cs.getPropertyValue("overflow-x");
let overflowy = cs.getPropertyValue("overflow-y");
// we already discarded non-multiline selects so allow vertical
// scroll for multiline ones directly without checking for a
// overflow property
let scrollVert =
node.scrollTopMax &&
(global.HTMLSelectElement.isInstance(node) ||
scrollingAllowed.includes(overflowy));
// do not allow horizontal scrolling for select elements, it leads
// to visual artifacts and is not the expected behavior anyway
if (
!global.HTMLSelectElement.isInstance(node) &&
node.scrollLeftMin != node.scrollLeftMax &&
scrollingAllowed.includes(overflowx)
) {
return scrollVert ? "NSEW" : "EW";
}
if (scrollVert) {
return "NS";
}
return null;
}
findNearestScrollableElement(aNode) {
// go upward in the DOM and find any parent element that has a overflow
// area and can therefore be scrolled
this._scrollable = null;
for (let node = aNode; node; node = node.flattenedTreeParentNode) {
// do not use overflow based autoscroll for <html> and <body>
// Elements or non-html/non-xul elements such as svg or Document nodes
// also make sure to skip select elements that are not multiline
let direction = this.computeNodeScrollDirection(node);
if (direction) {
this._scrolldir = direction;
this._scrollable = node;
break;
}
}
if (!this._scrollable) {
let direction = this.computeWindowScrollDirection(aNode.ownerGlobal);
if (direction) {
this._scrolldir = direction;
this._scrollable = aNode.ownerGlobal;
} else if (aNode.ownerGlobal.frameElement) {
// Note, in case of out of process iframes frameElement is null, and
// a caller is supposed to communicate to iframe's parent on its own to
// support cross process scrolling.
this.findNearestScrollableElement(aNode.ownerGlobal.frameElement);
}
}
}
async startScroll(event) {
this.findNearestScrollableElement(event.originalTarget);
if (!this._scrollable) {
this.sendAsyncMessage("Autoscroll:MaybeStartInParent", {
browsingContextId: this.browsingContext.id,
screenX: event.screenX,
screenY: event.screenY,
});
return;
}
let content = event.originalTarget.ownerGlobal;
// In some configurations like Print Preview, content.performance
// (which we use below) is null. Autoscrolling is broken in Print
// Preview anyways (see bug 1393494), so just don't start it at all.
if (!content.performance) {
return;
}
let domUtils = content.windowUtils;
let scrollable = this._scrollable;
if (scrollable instanceof Ci.nsIDOMWindow) {
// getViewId() needs an element to operate on.
scrollable = scrollable.document.documentElement;
}
this._scrollId = null;
try {
this._scrollId = domUtils.getViewId(scrollable);
} catch (e) {
// No view ID - leave this._scrollId as null. Receiving side will check.
}
let presShellId = domUtils.getPresShellId();
let { autoscrollEnabled, usingApz } = await this.sendQuery(
"Autoscroll:Start",
{
scrolldir: this._scrolldir,
screenXDevPx: event.screenX * content.devicePixelRatio,
screenYDevPx: event.screenY * content.devicePixelRatio,
scrollId: this._scrollId,
presShellId,
browsingContext: this.browsingContext,
}
);
if (!autoscrollEnabled) {
this._scrollable = null;
return;
}
this.document.addEventListener("mousemove", this, {
capture: true,
mozSystemGroup: true,
});
this.document.addEventListener("mouseup", this, {
capture: true,
mozSystemGroup: true,
});
this.document.addEventListener("pagehide", this, true);
this._startX = event.screenX;
this._startY = event.screenY;
this._screenX = event.screenX;
this._screenY = event.screenY;
this._scrollErrorX = 0;
this._scrollErrorY = 0;
this._autoscrollHandledByApz = usingApz;
if (!usingApz) {
// If the browser didn't hand the autoscroll off to APZ,
// scroll here in the main thread.
this.startMainThreadScroll();
} else {
// Even if the browser did hand the autoscroll to APZ,
// APZ might reject it in which case it will notify us
// and we need to take over.
Services.obs.addObserver(this.observer, "autoscroll-rejected-by-apz");
}
if (Cu.isInAutomation) {
Services.obs.notifyObservers(content, "autoscroll-start");
}
}
startMainThreadScroll() {
let content = this.document.defaultView;
this._lastFrame = content.performance.now();
content.requestAnimationFrame(this.autoscrollLoop);
}
stopScroll() {
if (this._scrollable) {
this._scrollable.mozScrollSnap();
this._scrollable = null;
this.document.removeEventListener("mousemove", this, {
capture: true,
mozSystemGroup: true,
});
this.document.removeEventListener("mouseup", this, {
capture: true,
mozSystemGroup: true,
});
this.document.removeEventListener("pagehide", this, true);
if (this._autoscrollHandledByApz) {
Services.obs.removeObserver(
this.observer,
"autoscroll-rejected-by-apz"
);
}
}
}
accelerate(curr, start) {
const speed = 12;
var val = (curr - start) / speed;
if (val > 1) {
return val * Math.sqrt(val) - 1;
}
if (val < -1) {
return val * Math.sqrt(-val) + 1;
}
return 0;
}
roundToZero(num) {
if (num > 0) {
return Math.floor(num);
}
return Math.ceil(num);
}
autoscrollLoop(timestamp) {
if (!this._scrollable) {
// Scrolling has been canceled
return;
}
// avoid long jumps when the browser hangs for more than
// |maxTimeDelta| ms
const maxTimeDelta = 100;
var timeDelta = Math.min(maxTimeDelta, timestamp - this._lastFrame);
// we used to scroll |accelerate()| pixels every 20ms (50fps)
var timeCompensation = timeDelta / 20;
this._lastFrame = timestamp;
var actualScrollX = 0;
var actualScrollY = 0;
// don't bother scrolling vertically when the scrolldir is only horizontal
// and the other way around
if (this._scrolldir != "EW") {
var y = this.accelerate(this._screenY, this._startY) * timeCompensation;
var desiredScrollY = this._scrollErrorY + y;
actualScrollY = this.roundToZero(desiredScrollY);
this._scrollErrorY = desiredScrollY - actualScrollY;
}
if (this._scrolldir != "NS") {
var x = this.accelerate(this._screenX, this._startX) * timeCompensation;
var desiredScrollX = this._scrollErrorX + x;
actualScrollX = this.roundToZero(desiredScrollX);
this._scrollErrorX = desiredScrollX - actualScrollX;
}
this._scrollable.scrollBy({
left: actualScrollX,
top: actualScrollY,
behavior: "instant",
});
this._scrollable.ownerGlobal.requestAnimationFrame(this.autoscrollLoop);
}
canStartAutoScrollWith(event) {
if (
!event.isTrusted ||
event.defaultPrevented ||
event.button !== 1 ||
event.clickEventPrevented()
) {
return false;
}
for (const modifier of ["shift", "alt", "ctrl", "meta"]) {
if (
event[modifier + "Key"] &&
Services.prefs.getBoolPref(
`general.autoscroll.prevent_to_start.${modifier}Key`,
false
)
) {
return false;
}
}
return true;
}
handleEvent(event) {
switch (event.type) {
case "mousemove":
this._screenX = event.screenX;
this._screenY = event.screenY;
break;
case "mousedown":
if (
this.canStartAutoScrollWith(event) &&
!this._scrollable &&
!this.isAutoscrollBlocker(event)
) {
this.startScroll(event);
}
// fallthrough
case "mouseup":
if (
this._scrollable &&
Services.prefs.getBoolPref("general.autoscroll", false)
) {
// Middle mouse click event shouldn't be fired in web content for
// compatibility with Chrome.
event.preventClickEvent();
}
break;
case "pagehide":
if (this._scrollable) {
var doc = this._scrollable.ownerDocument || this._scrollable.document;
if (doc == event.target) {
this.sendAsyncMessage("Autoscroll:Cancel");
this.stopScroll();
}
}
break;
}
}
receiveMessage(msg) {
let data = msg.data;
switch (msg.name) {
case "Autoscroll:MaybeStart":
for (let child of this.browsingContext.children) {
if (data.browsingContextId == child.id) {
this.startScroll({
screenX: data.screenX,
screenY: data.screenY,
originalTarget: child.embedderElement,
});
break;
}
}
break;
case "Autoscroll:Stop": {
this.stopScroll();
break;
}
}
}
rejectedByApz(data) {
// The caller passes in the scroll id via 'data'.
if (data == this._scrollId) {
this._autoscrollHandledByApz = false;
this.startMainThreadScroll();
Services.obs.removeObserver(this.observer, "autoscroll-rejected-by-apz");
}
}
}
class AutoScrollObserver {
constructor(actor) {
this.actor = actor;
}
observe(subject, topic, data) {
if (topic === "autoscroll-rejected-by-apz") {
this.actor.rejectedByApz(data);
}
}
}