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