Bug 1869043 allow a device to be specified with MediaTrackGraph::NotifyWhenDeviceStar...
[gecko.git] / toolkit / actors / AutoScrollChild.sys.mjs
blob25e2ae77a55ea5f7b40ccb581041bde950fe3fa2
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 const lazy = {};
8 ChromeUtils.defineESModuleGetters(lazy, {
9   BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
10 });
12 export class AutoScrollChild extends JSWindowActorChild {
13   constructor() {
14     super();
16     this._scrollable = null;
17     this._scrolldir = "";
18     this._startX = null;
19     this._startY = null;
20     this._screenX = null;
21     this._screenY = null;
22     this._lastFrame = null;
23     this._autoscrollHandledByApz = false;
24     this._scrollId = null;
26     this.observer = new AutoScrollObserver(this);
27     this.autoscrollLoop = this.autoscrollLoop.bind(this);
28   }
30   isAutoscrollBlocker(event) {
31     let mmPaste = Services.prefs.getBoolPref("middlemouse.paste");
32     let mmScrollbarPosition = Services.prefs.getBoolPref(
33       "middlemouse.scrollbarPosition"
34     );
35     let node = event.originalTarget;
36     let content = node.ownerGlobal;
38     // If the node is in editable document or content, we don't want to start
39     // autoscroll.
40     if (mmPaste) {
41       if (node.ownerDocument?.designMode == "on") {
42         return true;
43       }
44       const element =
45         node.nodeType === content.Node.ELEMENT_NODE ? node : node.parentElement;
46       if (element.isContentEditable) {
47         return true;
48       }
49     }
51     // Don't start if we're on a link.
52     let [href] = lazy.BrowserUtils.hrefAndLinkNodeForClickEvent(event);
53     if (href) {
54       return true;
55     }
57     // Or if we're pasting into an input field of sorts.
58     let closestInput = mmPaste && node.closest("input,textarea");
59     if (
60       content.HTMLInputElement.isInstance(closestInput) ||
61       content.HTMLTextAreaElement.isInstance(closestInput)
62     ) {
63       return true;
64     }
66     // Or if we're on a scrollbar or XUL <tree>
67     if (
68       (mmScrollbarPosition &&
69         content.XULElement.isInstance(
70           node.closest("scrollbar,scrollcorner")
71         )) ||
72       content.XULElement.isInstance(node.closest("treechildren"))
73     ) {
74       return true;
75     }
76     return false;
77   }
79   isScrollableElement(aNode) {
80     let content = aNode.ownerGlobal;
81     if (content.HTMLElement.isInstance(aNode)) {
82       return !content.HTMLSelectElement.isInstance(aNode) || aNode.multiple;
83     }
85     return content.XULElement.isInstance(aNode);
86   }
88   computeWindowScrollDirection(global) {
89     if (!global.scrollbars.visible) {
90       return null;
91     }
92     if (global.scrollMaxX != global.scrollMinX) {
93       return global.scrollMaxY != global.scrollMinY ? "NSEW" : "EW";
94     }
95     if (global.scrollMaxY != global.scrollMinY) {
96       return "NS";
97     }
98     return null;
99   }
101   computeNodeScrollDirection(node) {
102     if (!this.isScrollableElement(node)) {
103       return null;
104     }
106     let global = node.ownerGlobal;
108     // this is a list of overflow property values that allow scrolling
109     const scrollingAllowed = ["scroll", "auto"];
111     let cs = global.getComputedStyle(node);
112     let overflowx = cs.getPropertyValue("overflow-x");
113     let overflowy = cs.getPropertyValue("overflow-y");
114     // we already discarded non-multiline selects so allow vertical
115     // scroll for multiline ones directly without checking for a
116     // overflow property
117     let scrollVert =
118       node.scrollTopMax &&
119       (global.HTMLSelectElement.isInstance(node) ||
120         scrollingAllowed.includes(overflowy));
122     // do not allow horizontal scrolling for select elements, it leads
123     // to visual artifacts and is not the expected behavior anyway
124     if (
125       !global.HTMLSelectElement.isInstance(node) &&
126       node.scrollLeftMin != node.scrollLeftMax &&
127       scrollingAllowed.includes(overflowx)
128     ) {
129       return scrollVert ? "NSEW" : "EW";
130     }
132     if (scrollVert) {
133       return "NS";
134     }
136     return null;
137   }
139   findNearestScrollableElement(aNode) {
140     // go upward in the DOM and find any parent element that has a overflow
141     // area and can therefore be scrolled
142     this._scrollable = null;
143     for (let node = aNode; node; node = node.flattenedTreeParentNode) {
144       // do not use overflow based autoscroll for <html> and <body>
145       // Elements or non-html/non-xul elements such as svg or Document nodes
146       // also make sure to skip select elements that are not multiline
147       let direction = this.computeNodeScrollDirection(node);
148       if (direction) {
149         this._scrolldir = direction;
150         this._scrollable = node;
151         break;
152       }
153     }
155     if (!this._scrollable) {
156       let direction = this.computeWindowScrollDirection(aNode.ownerGlobal);
157       if (direction) {
158         this._scrolldir = direction;
159         this._scrollable = aNode.ownerGlobal;
160       } else if (aNode.ownerGlobal.frameElement) {
161         // Note, in case of out of process iframes frameElement is null, and
162         // a caller is supposed to communicate to iframe's parent on its own to
163         // support cross process scrolling.
164         this.findNearestScrollableElement(aNode.ownerGlobal.frameElement);
165       }
166     }
167   }
169   async startScroll(event) {
170     this.findNearestScrollableElement(event.originalTarget);
171     if (!this._scrollable) {
172       this.sendAsyncMessage("Autoscroll:MaybeStartInParent", {
173         browsingContextId: this.browsingContext.id,
174         screenX: event.screenX,
175         screenY: event.screenY,
176       });
177       return;
178     }
180     let content = event.originalTarget.ownerGlobal;
182     // In some configurations like Print Preview, content.performance
183     // (which we use below) is null. Autoscrolling is broken in Print
184     // Preview anyways (see bug 1393494), so just don't start it at all.
185     if (!content.performance) {
186       return;
187     }
189     let domUtils = content.windowUtils;
190     let scrollable = this._scrollable;
191     if (scrollable instanceof Ci.nsIDOMWindow) {
192       // getViewId() needs an element to operate on.
193       scrollable = scrollable.document.documentElement;
194     }
195     this._scrollId = null;
196     try {
197       this._scrollId = domUtils.getViewId(scrollable);
198     } catch (e) {
199       // No view ID - leave this._scrollId as null. Receiving side will check.
200     }
201     let presShellId = domUtils.getPresShellId();
202     let { autoscrollEnabled, usingApz } = await this.sendQuery(
203       "Autoscroll:Start",
204       {
205         scrolldir: this._scrolldir,
206         screenXDevPx: event.screenX * content.devicePixelRatio,
207         screenYDevPx: event.screenY * content.devicePixelRatio,
208         scrollId: this._scrollId,
209         presShellId,
210         browsingContext: this.browsingContext,
211       }
212     );
213     if (!autoscrollEnabled) {
214       this._scrollable = null;
215       return;
216     }
218     Services.els.addSystemEventListener(this.document, "mousemove", this, true);
219     Services.els.addSystemEventListener(this.document, "mouseup", this, true);
220     this.document.addEventListener("pagehide", this, true);
222     this._startX = event.screenX;
223     this._startY = event.screenY;
224     this._screenX = event.screenX;
225     this._screenY = event.screenY;
226     this._scrollErrorX = 0;
227     this._scrollErrorY = 0;
228     this._autoscrollHandledByApz = usingApz;
230     if (!usingApz) {
231       // If the browser didn't hand the autoscroll off to APZ,
232       // scroll here in the main thread.
233       this.startMainThreadScroll();
234     } else {
235       // Even if the browser did hand the autoscroll to APZ,
236       // APZ might reject it in which case it will notify us
237       // and we need to take over.
238       Services.obs.addObserver(this.observer, "autoscroll-rejected-by-apz");
239     }
241     if (Cu.isInAutomation) {
242       Services.obs.notifyObservers(content, "autoscroll-start");
243     }
244   }
246   startMainThreadScroll() {
247     let content = this.document.defaultView;
248     this._lastFrame = content.performance.now();
249     content.requestAnimationFrame(this.autoscrollLoop);
250   }
252   stopScroll() {
253     if (this._scrollable) {
254       this._scrollable.mozScrollSnap();
255       this._scrollable = null;
257       Services.els.removeSystemEventListener(
258         this.document,
259         "mousemove",
260         this,
261         true
262       );
263       Services.els.removeSystemEventListener(
264         this.document,
265         "mouseup",
266         this,
267         true
268       );
269       this.document.removeEventListener("pagehide", this, true);
270       if (this._autoscrollHandledByApz) {
271         Services.obs.removeObserver(
272           this.observer,
273           "autoscroll-rejected-by-apz"
274         );
275       }
276     }
277   }
279   accelerate(curr, start) {
280     const speed = 12;
281     var val = (curr - start) / speed;
283     if (val > 1) {
284       return val * Math.sqrt(val) - 1;
285     }
286     if (val < -1) {
287       return val * Math.sqrt(-val) + 1;
288     }
289     return 0;
290   }
292   roundToZero(num) {
293     if (num > 0) {
294       return Math.floor(num);
295     }
296     return Math.ceil(num);
297   }
299   autoscrollLoop(timestamp) {
300     if (!this._scrollable) {
301       // Scrolling has been canceled
302       return;
303     }
305     // avoid long jumps when the browser hangs for more than
306     // |maxTimeDelta| ms
307     const maxTimeDelta = 100;
308     var timeDelta = Math.min(maxTimeDelta, timestamp - this._lastFrame);
309     // we used to scroll |accelerate()| pixels every 20ms (50fps)
310     var timeCompensation = timeDelta / 20;
311     this._lastFrame = timestamp;
313     var actualScrollX = 0;
314     var actualScrollY = 0;
315     // don't bother scrolling vertically when the scrolldir is only horizontal
316     // and the other way around
317     if (this._scrolldir != "EW") {
318       var y = this.accelerate(this._screenY, this._startY) * timeCompensation;
319       var desiredScrollY = this._scrollErrorY + y;
320       actualScrollY = this.roundToZero(desiredScrollY);
321       this._scrollErrorY = desiredScrollY - actualScrollY;
322     }
323     if (this._scrolldir != "NS") {
324       var x = this.accelerate(this._screenX, this._startX) * timeCompensation;
325       var desiredScrollX = this._scrollErrorX + x;
326       actualScrollX = this.roundToZero(desiredScrollX);
327       this._scrollErrorX = desiredScrollX - actualScrollX;
328     }
330     this._scrollable.scrollBy({
331       left: actualScrollX,
332       top: actualScrollY,
333       behavior: "instant",
334     });
336     this._scrollable.ownerGlobal.requestAnimationFrame(this.autoscrollLoop);
337   }
339   canStartAutoScrollWith(event) {
340     if (
341       !event.isTrusted ||
342       event.defaultPrevented ||
343       event.button !== 1 ||
344       event.clickEventPrevented()
345     ) {
346       return false;
347     }
349     for (const modifier of ["shift", "alt", "ctrl", "meta"]) {
350       if (
351         event[modifier + "Key"] &&
352         Services.prefs.getBoolPref(
353           `general.autoscroll.prevent_to_start.${modifier}Key`,
354           false
355         )
356       ) {
357         return false;
358       }
359     }
360     return true;
361   }
363   handleEvent(event) {
364     switch (event.type) {
365       case "mousemove":
366         this._screenX = event.screenX;
367         this._screenY = event.screenY;
368         break;
369       case "mousedown":
370         if (
371           this.canStartAutoScrollWith(event) &&
372           !this._scrollable &&
373           !this.isAutoscrollBlocker(event)
374         ) {
375           this.startScroll(event);
376         }
377       // fallthrough
378       case "mouseup":
379         if (
380           this._scrollable &&
381           Services.prefs.getBoolPref("general.autoscroll", false)
382         ) {
383           // Middle mouse click event shouldn't be fired in web content for
384           // compatibility with Chrome.
385           event.preventClickEvent();
386         }
387         break;
388       case "pagehide":
389         if (this._scrollable) {
390           var doc = this._scrollable.ownerDocument || this._scrollable.document;
391           if (doc == event.target) {
392             this.sendAsyncMessage("Autoscroll:Cancel");
393             this.stopScroll();
394           }
395         }
396         break;
397     }
398   }
400   receiveMessage(msg) {
401     let data = msg.data;
402     switch (msg.name) {
403       case "Autoscroll:MaybeStart":
404         for (let child of this.browsingContext.children) {
405           if (data.browsingContextId == child.id) {
406             this.startScroll({
407               screenX: data.screenX,
408               screenY: data.screenY,
409               originalTarget: child.embedderElement,
410             });
411             break;
412           }
413         }
414         break;
415       case "Autoscroll:Stop": {
416         this.stopScroll();
417         break;
418       }
419     }
420   }
422   rejectedByApz(data) {
423     // The caller passes in the scroll id via 'data'.
424     if (data == this._scrollId) {
425       this._autoscrollHandledByApz = false;
426       this.startMainThreadScroll();
427       Services.obs.removeObserver(this.observer, "autoscroll-rejected-by-apz");
428     }
429   }
432 class AutoScrollObserver {
433   constructor(actor) {
434     this.actor = actor;
435   }
437   observe(subject, topic, data) {
438     if (topic === "autoscroll-rejected-by-apz") {
439       this.actor.rejectedByApz(data);
440     }
441   }