diff options
Diffstat (limited to 'toolkit/actors/AutoScrollChild.sys.mjs')
-rw-r--r-- | toolkit/actors/AutoScrollChild.sys.mjs | 442 |
1 files changed, 442 insertions, 0 deletions
diff --git a/toolkit/actors/AutoScrollChild.sys.mjs b/toolkit/actors/AutoScrollChild.sys.mjs new file mode 100644 index 0000000000..25e2ae77a5 --- /dev/null +++ b/toolkit/actors/AutoScrollChild.sys.mjs @@ -0,0 +1,442 @@ +/* -*- 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; + } + + Services.els.addSystemEventListener(this.document, "mousemove", this, true); + Services.els.addSystemEventListener(this.document, "mouseup", this, 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; + + Services.els.removeSystemEventListener( + this.document, + "mousemove", + this, + true + ); + Services.els.removeSystemEventListener( + this.document, + "mouseup", + this, + 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); + } + } +} |