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/. */
5
6const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
7
8var EXPORTED_SYMBOLS = ["AutoScrollChild"];
9
10class AutoScrollChild extends JSWindowActorChild {
11  constructor() {
12    super();
13
14    this._scrollable = null;
15    this._scrolldir = "";
16    this._startX = null;
17    this._startY = null;
18    this._screenX = null;
19    this._screenY = null;
20    this._lastFrame = null;
21    this._autoscrollHandledByApz = false;
22    this._scrollId = null;
23
24    this.observer = new AutoScrollObserver(this);
25    this.autoscrollLoop = this.autoscrollLoop.bind(this);
26  }
27
28  isAutoscrollBlocker(node) {
29    let mmPaste = Services.prefs.getBoolPref("middlemouse.paste");
30    let mmScrollbarPosition = Services.prefs.getBoolPref(
31      "middlemouse.scrollbarPosition"
32    );
33    let content = node.ownerGlobal;
34
35    while (node) {
36      if (
37        (node instanceof content.HTMLAnchorElement ||
38          node instanceof content.HTMLAreaElement) &&
39        node.hasAttribute("href")
40      ) {
41        return true;
42      }
43
44      if (
45        mmPaste &&
46        (node instanceof content.HTMLInputElement ||
47          node instanceof content.HTMLTextAreaElement)
48      ) {
49        return true;
50      }
51
52      if (
53        node instanceof content.XULElement &&
54        ((mmScrollbarPosition &&
55          (node.localName == "scrollbar" ||
56            node.localName == "scrollcorner")) ||
57          node.localName == "treechildren")
58      ) {
59        return true;
60      }
61
62      node = node.parentNode;
63    }
64    return false;
65  }
66
67  isScrollableElement(aNode) {
68    let content = aNode.ownerGlobal;
69    if (aNode instanceof content.HTMLElement) {
70      return !(aNode instanceof content.HTMLSelectElement) || aNode.multiple;
71    }
72
73    return aNode instanceof content.XULElement;
74  }
75
76  computeWindowScrollDirection(global) {
77    if (!global.scrollbars.visible) {
78      return null;
79    }
80    if (global.scrollMaxX != global.scrollMinX) {
81      return global.scrollMaxY != global.scrollMinY ? "NSEW" : "EW";
82    }
83    if (global.scrollMaxY != global.scrollMinY) {
84      return "NS";
85    }
86    return null;
87  }
88
89  computeNodeScrollDirection(node) {
90    if (!this.isScrollableElement(node)) {
91      return null;
92    }
93
94    let global = node.ownerGlobal;
95
96    // this is a list of overflow property values that allow scrolling
97    const scrollingAllowed = ["scroll", "auto"];
98
99    let cs = global.getComputedStyle(node);
100    let overflowx = cs.getPropertyValue("overflow-x");
101    let overflowy = cs.getPropertyValue("overflow-y");
102    // we already discarded non-multiline selects so allow vertical
103    // scroll for multiline ones directly without checking for a
104    // overflow property
105    let scrollVert =
106      node.scrollTopMax &&
107      (node instanceof global.HTMLSelectElement ||
108        scrollingAllowed.includes(overflowy));
109
110    // do not allow horizontal scrolling for select elements, it leads
111    // to visual artifacts and is not the expected behavior anyway
112    if (
113      !(node instanceof global.HTMLSelectElement) &&
114      node.scrollLeftMin != node.scrollLeftMax &&
115      scrollingAllowed.includes(overflowx)
116    ) {
117      return scrollVert ? "NSEW" : "EW";
118    }
119
120    if (scrollVert) {
121      return "NS";
122    }
123
124    return null;
125  }
126
127  findNearestScrollableElement(aNode) {
128    // go upward in the DOM and find any parent element that has a overflow
129    // area and can therefore be scrolled
130    this._scrollable = null;
131    for (let node = aNode; node; node = node.flattenedTreeParentNode) {
132      // do not use overflow based autoscroll for <html> and <body>
133      // Elements or non-html/non-xul elements such as svg or Document nodes
134      // also make sure to skip select elements that are not multiline
135      let direction = this.computeNodeScrollDirection(node);
136      if (direction) {
137        this._scrolldir = direction;
138        this._scrollable = node;
139        break;
140      }
141    }
142
143    if (!this._scrollable) {
144      let direction = this.computeWindowScrollDirection(aNode.ownerGlobal);
145      if (direction) {
146        this._scrolldir = direction;
147        this._scrollable = aNode.ownerGlobal;
148      } else if (aNode.ownerGlobal.frameElement) {
149        // Note, in case of out of process iframes frameElement is null, and
150        // a caller is supposed to communicate to iframe's parent on its own to
151        // support cross process scrolling.
152        this.findNearestScrollableElement(aNode.ownerGlobal.frameElement);
153      }
154    }
155  }
156
157  async startScroll(event) {
158    this.findNearestScrollableElement(event.originalTarget);
159    if (!this._scrollable) {
160      this.sendAsyncMessage("Autoscroll:MaybeStartInParent", {
161        browsingContextId: this.browsingContext.id,
162        screenX: event.screenX,
163        screenY: event.screenY,
164      });
165      return;
166    }
167
168    let content = event.originalTarget.ownerGlobal;
169
170    // In some configurations like Print Preview, content.performance
171    // (which we use below) is null. Autoscrolling is broken in Print
172    // Preview anyways (see bug 1393494), so just don't start it at all.
173    if (!content.performance) {
174      return;
175    }
176
177    let domUtils = content.windowUtils;
178    let scrollable = this._scrollable;
179    if (scrollable instanceof Ci.nsIDOMWindow) {
180      // getViewId() needs an element to operate on.
181      scrollable = scrollable.document.documentElement;
182    }
183    this._scrollId = null;
184    try {
185      this._scrollId = domUtils.getViewId(scrollable);
186    } catch (e) {
187      // No view ID - leave this._scrollId as null. Receiving side will check.
188    }
189    let presShellId = domUtils.getPresShellId();
190    let { autoscrollEnabled, usingApz } = await this.sendQuery(
191      "Autoscroll:Start",
192      {
193        scrolldir: this._scrolldir,
194        screenX: event.screenX,
195        screenY: event.screenY,
196        scrollId: this._scrollId,
197        presShellId,
198        browsingContext: this.browsingContext,
199      }
200    );
201    if (!autoscrollEnabled) {
202      this._scrollable = null;
203      return;
204    }
205
206    Services.els.addSystemEventListener(this.document, "mousemove", this, true);
207    this.document.addEventListener("pagehide", this, true);
208
209    this._ignoreMouseEvents = true;
210    this._startX = event.screenX;
211    this._startY = event.screenY;
212    this._screenX = event.screenX;
213    this._screenY = event.screenY;
214    this._scrollErrorX = 0;
215    this._scrollErrorY = 0;
216    this._autoscrollHandledByApz = usingApz;
217
218    if (!usingApz) {
219      // If the browser didn't hand the autoscroll off to APZ,
220      // scroll here in the main thread.
221      this.startMainThreadScroll();
222    } else {
223      // Even if the browser did hand the autoscroll to APZ,
224      // APZ might reject it in which case it will notify us
225      // and we need to take over.
226      Services.obs.addObserver(this.observer, "autoscroll-rejected-by-apz");
227    }
228
229    if (Cu.isInAutomation) {
230      Services.obs.notifyObservers(content, "autoscroll-start");
231    }
232  }
233
234  startMainThreadScroll() {
235    let content = this.document.defaultView;
236    this._lastFrame = content.performance.now();
237    content.requestAnimationFrame(this.autoscrollLoop);
238  }
239
240  stopScroll() {
241    if (this._scrollable) {
242      this._scrollable.mozScrollSnap();
243      this._scrollable = null;
244
245      Services.els.removeSystemEventListener(
246        this.document,
247        "mousemove",
248        this,
249        true
250      );
251      this.document.removeEventListener("pagehide", this, true);
252      if (this._autoscrollHandledByApz) {
253        Services.obs.removeObserver(
254          this.observer,
255          "autoscroll-rejected-by-apz"
256        );
257      }
258    }
259  }
260
261  accelerate(curr, start) {
262    const speed = 12;
263    var val = (curr - start) / speed;
264
265    if (val > 1) {
266      return val * Math.sqrt(val) - 1;
267    }
268    if (val < -1) {
269      return val * Math.sqrt(-val) + 1;
270    }
271    return 0;
272  }
273
274  roundToZero(num) {
275    if (num > 0) {
276      return Math.floor(num);
277    }
278    return Math.ceil(num);
279  }
280
281  autoscrollLoop(timestamp) {
282    if (!this._scrollable) {
283      // Scrolling has been canceled
284      return;
285    }
286
287    // avoid long jumps when the browser hangs for more than
288    // |maxTimeDelta| ms
289    const maxTimeDelta = 100;
290    var timeDelta = Math.min(maxTimeDelta, timestamp - this._lastFrame);
291    // we used to scroll |accelerate()| pixels every 20ms (50fps)
292    var timeCompensation = timeDelta / 20;
293    this._lastFrame = timestamp;
294
295    var actualScrollX = 0;
296    var actualScrollY = 0;
297    // don't bother scrolling vertically when the scrolldir is only horizontal
298    // and the other way around
299    if (this._scrolldir != "EW") {
300      var y = this.accelerate(this._screenY, this._startY) * timeCompensation;
301      var desiredScrollY = this._scrollErrorY + y;
302      actualScrollY = this.roundToZero(desiredScrollY);
303      this._scrollErrorY = desiredScrollY - actualScrollY;
304    }
305    if (this._scrolldir != "NS") {
306      var x = this.accelerate(this._screenX, this._startX) * timeCompensation;
307      var desiredScrollX = this._scrollErrorX + x;
308      actualScrollX = this.roundToZero(desiredScrollX);
309      this._scrollErrorX = desiredScrollX - actualScrollX;
310    }
311
312    this._scrollable.scrollBy({
313      left: actualScrollX,
314      top: actualScrollY,
315      behavior: "instant",
316    });
317
318    this._scrollable.ownerGlobal.requestAnimationFrame(this.autoscrollLoop);
319  }
320
321  handleEvent(event) {
322    if (event.type == "mousemove") {
323      this._screenX = event.screenX;
324      this._screenY = event.screenY;
325    } else if (event.type == "mousedown") {
326      if (
327        event.isTrusted & !event.defaultPrevented &&
328        event.button == 1 &&
329        !this._scrollable &&
330        !this.isAutoscrollBlocker(event.originalTarget)
331      ) {
332        this.startScroll(event);
333      }
334    } else if (event.type == "pagehide") {
335      if (this._scrollable) {
336        var doc = this._scrollable.ownerDocument || this._scrollable.document;
337        if (doc == event.target) {
338          this.sendAsyncMessage("Autoscroll:Cancel");
339          this.stopScroll();
340        }
341      }
342    }
343  }
344
345  receiveMessage(msg) {
346    let data = msg.data;
347    switch (msg.name) {
348      case "Autoscroll:MaybeStart":
349        for (let child of this.browsingContext.children) {
350          if (data.browsingContextId == child.id) {
351            this.startScroll({
352              screenX: data.screenX,
353              screenY: data.screenY,
354              originalTarget: child.embedderElement,
355            });
356            break;
357          }
358        }
359        break;
360      case "Autoscroll:Stop": {
361        this.stopScroll();
362        break;
363      }
364    }
365  }
366
367  rejectedByApz(data) {
368    // The caller passes in the scroll id via 'data'.
369    if (data == this._scrollId) {
370      this._autoscrollHandledByApz = false;
371      this.startMainThreadScroll();
372      Services.obs.removeObserver(this.observer, "autoscroll-rejected-by-apz");
373    }
374  }
375}
376
377class AutoScrollObserver {
378  constructor(actor) {
379    this.actor = actor;
380  }
381
382  observe(subject, topic, data) {
383    if (topic === "autoscroll-rejected-by-apz") {
384      this.actor.rejectedByApz(data);
385    }
386  }
387}
388