OLD | NEW |
(Empty) | |
| 1 // Copyright 2015 The Chromium Authors. All rights reserved. |
| 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. |
| 4 |
| 5 (function(global, binding, v8) { |
| 6 'use strict'; |
| 7 |
| 8 // V8 "Imports": |
| 9 // - %CreatePrivateOwnSymbol |
| 10 // - %_CallFunction |
| 11 // - %AddNamedProperty |
| 12 // - %HasOwnProperty |
| 13 // - $promiseThen, $promiseCreate, $promiseResolve, $promiseReject |
| 14 // - InternalPackedArray |
| 15 |
| 16 |
| 17 const Number = global.Number; |
| 18 const Math = global.Math; |
| 19 const Symbol = global.Symbol; |
| 20 const Date = global.Date; |
| 21 const Map = global.Map; |
| 22 // const performance = window.performance; |
| 23 // const console = window.console; |
| 24 |
| 25 const undefined = void 0; |
| 26 const DONT_ENUM = 2; |
| 27 const FLING_VELOCITY_THRESHOLD = 400; // dip/s |
| 28 |
| 29 var snapper = Symbol('[[Snapper]]'); |
| 30 |
| 31 // A reference to the window which is initialized by SnapManager constructor |
| 32 // This is needed because {request,cancel}AnimationFrame are not exposed in gl
obal |
| 33 let window; |
| 34 |
| 35 function log() { |
| 36 console.log.apply(console, arguments); |
| 37 var message = ""; |
| 38 for (var k in arguments) { |
| 39 message += arguments[k]; |
| 40 } |
| 41 |
| 42 //%DebugPrint(message); |
| 43 } |
| 44 |
| 45 /* |
| 46 * Responsible for receiving updates from Blink about changes to snap containe
rs |
| 47 */ |
| 48 class SnapManager { |
| 49 constructor(win) { |
| 50 log('Initialized snap manager'); |
| 51 log(win); |
| 52 log(win.requestAnimationFrame); |
| 53 window = win; |
| 54 } |
| 55 |
| 56 updateSnapContainer(element, horizontalOffsets, verticalOffsets) { |
| 57 log(element); |
| 58 if (!element[snapper]) { |
| 59 // TODO(majidvp): I have put the snap related functionality in snap cont
roller but perhaps |
| 60 // they can be in the elements prototype? |
| 61 element[snapper] = new Snapper(element); |
| 62 // Customize scrolling by override native scroll handler |
| 63 if (element.setApplyScroll) { |
| 64 element.setApplyScroll(element[snapper].applyScroll, "perform-before-n
ative-scroll"); |
| 65 } else { |
| 66 log("Element::setApplyScroll is missing. Make sure ScrollCustomization
feature is enabled."); |
| 67 } |
| 68 } |
| 69 |
| 70 element[snapper].setSnapOffsets(horizontalOffsets, verticalOffsets); |
| 71 } |
| 72 } |
| 73 |
| 74 /* Encapsulates the logic for determining when and where to snap for each snap
container */ |
| 75 class Snapper { |
| 76 constructor(element) { |
| 77 this.element = element; |
| 78 // Make applyScroll bounded as it is called using function invocation patt
ern |
| 79 this.applyScroll = this.applyScroll.bind(this); |
| 80 } |
| 81 |
| 82 setSnapOffsets(h, v) { |
| 83 this.snapOffsets = [h, v]; |
| 84 // TODO: perhaps update inflight snaps? |
| 85 } |
| 86 |
| 87 // custom apply scroll handler |
| 88 applyScroll(scrollState) { |
| 89 log("scrollState", scrollState); |
| 90 if (scrollState.isBeginning) { |
| 91 // A new scroll clears all existing snap animations for this element |
| 92 this.clearSnapState(); |
| 93 AnimationController.instance().remove(this); |
| 94 return; |
| 95 } else if (!(scrollState.inInertialPhase || scrollState.isEnding)) { |
| 96 // Active user scrolling |
| 97 return; |
| 98 } |
| 99 let consumedDelta = this.snap(scrollState); |
| 100 scrollState.consumeDelta(consumedDelta[0], consumedDelta[1]); |
| 101 } |
| 102 |
| 103 |
| 104 // Make snapping decision separate on each dimension and creates an independ
ent |
| 105 // 1D snap animations for each. This allows us to fling in one direction whi
le |
| 106 // snap in another. |
| 107 snap(scrollState) { |
| 108 let consumedDelta = [0, 0]; |
| 109 |
| 110 for (let i of[0, 1]) { |
| 111 let property = i == 0 ? 'scrollLeft' : 'scrollTop'; |
| 112 let delta = i == 0 ? scrollState.deltaX : scrollState.deltaY; |
| 113 let velocity = i == 0 ? scrollState.velocityX : scrollState.velocityY; |
| 114 let position = this.scrollPosition()[i]; |
| 115 |
| 116 if (this.snapOffsets[i].length == 0) |
| 117 continue; |
| 118 |
| 119 // consume all scroll delta while snap is in progress preventing native
scroll |
| 120 if (this.isSnapping[i]) { |
| 121 consumedDelta[i] = delta; |
| 122 continue; |
| 123 } |
| 124 |
| 125 // Wait for fling to slow down before starting the snap |
| 126 // TODO(majidvp): Handle overscroll cases as well. One option is to clam
p the velocity when overscrolling |
| 127 if (scrollState.inInertialPhase && Math.abs(velocity) > FLING_VELOCITY_T
HRESHOLD) |
| 128 continue; |
| 129 |
| 130 if (scrollState.inInertialPhase && Math.abs(velocity) > 0 && !this.fling
Curves[i]) |
| 131 this.flingCurves[i] = this.createFlingCurve(position, velocity); |
| 132 |
| 133 let td = this.snapTargetAndDuration(position, velocity, this.snapOffsets
[i], this.flingCurves[i]), |
| 134 target = td.target, |
| 135 duration = td.duration; |
| 136 |
| 137 if (position == target) |
| 138 continue; |
| 139 |
| 140 // TODO(majidvp): Perhaps stay away from creating new animation objects
to reduce |
| 141 // V8 GC pressure |
| 142 let animation = new Animation({ |
| 143 element: this.element, |
| 144 property: property, |
| 145 begin: position, |
| 146 end: target, |
| 147 duration: duration, |
| 148 v0: velocity, |
| 149 curveType: 'motion' /* linear | motion */ |
| 150 }); |
| 151 |
| 152 AnimationController.instance().add(this, property, animation); |
| 153 this.isSnapping[i] = true; |
| 154 log('horizontal:' + this.snapOffsets[0] + ' vertical:' + this.snapOffs
ets[1]); |
| 155 } |
| 156 |
| 157 return consumedDelta; |
| 158 } |
| 159 |
| 160 clearSnapState() { |
| 161 this.flingCurves = []; |
| 162 this.isSnapping = [false, false]; |
| 163 } |
| 164 |
| 165 createFlingCurve(position, velocity) { |
| 166 let now = Utils.now(); |
| 167 return new FlingCurve(position, -1 * velocity, now); |
| 168 } |
| 169 |
| 170 snapTargetAndDuration(position, velocity, snapOffsets, flingCurve) { |
| 171 let target = flingCurve ? flingCurve.getFinalPosition() : position; |
| 172 |
| 173 //let snapTarget = Utils.snapWithInterval(target, velocity, this.repeat(),
size); |
| 174 let snapTarget = Utils.snapWithOffsets(target, velocity, snapOffsets); |
| 175 let snapDuration = this.estimateDuration(position, snapTarget, velocity, f
lingCurve); |
| 176 |
| 177 log('position:' + position + ' velocity:' + velocity + ' => target:' + tar
get + ' snapped-target: ' + snapTarget + ' @(' + snapDuration + ')'); |
| 178 return { |
| 179 target: snapTarget, |
| 180 duration: snapDuration |
| 181 }; |
| 182 } |
| 183 |
| 184 estimateDuration(current, target, velocity, flingCurve) { |
| 185 // If there is a fling curve then use its estimated duration otherwise |
| 186 // assume a constant snap speed to calculate duration. |
| 187 let SPEED = 2; // 2px/ms |
| 188 return flingCurve ? flingCurve.getDuration() * 1000 : Math.abs(target - cu
rrent) / SPEED; |
| 189 } |
| 190 |
| 191 scrollSize() { |
| 192 return [this.element.scrollWidth, this.element.scrollHeight]; |
| 193 } |
| 194 |
| 195 scrollPosition() { |
| 196 return [this.element.scrollLeft, this.element.scrollTop]; |
| 197 } |
| 198 } |
| 199 |
| 200 class Utils { |
| 201 static snapWithInterval(value, velocity, interval, max) { |
| 202 let roundingFn = velocity[i] == 0 ? Math.round : (velocity[i] > 0 ? Math.c
eil : Math.floor); |
| 203 return Utils.clamp(roundingFn(value[i] / interval) * interval, 0, max[i]); |
| 204 } |
| 205 |
| 206 // Find closest match in values |
| 207 static snapWithOffsets(value, velocity, offsets) { |
| 208 let snappedOffset = Utils.binarySearchClosest(value, offsets); |
| 209 return snappedOffset; |
| 210 } |
| 211 |
| 212 // Find the value in array that is closes to v |
| 213 static binarySearchClosest(v, array) { |
| 214 if (array.length == 0) |
| 215 return v; |
| 216 |
| 217 let left = 0, |
| 218 right = array.length; |
| 219 // Clamp value to ensure it falls in range range |
| 220 v = Utils.clamp(v, array[0], array[right - 1]); |
| 221 |
| 222 while (right - left > 1) { |
| 223 let mid = Math.ceil((right + left) / 2); |
| 224 if (v < array[mid]) |
| 225 right = mid; |
| 226 else |
| 227 left = mid; |
| 228 } |
| 229 |
| 230 // min <= v <= max |
| 231 let min = array[left], |
| 232 max = array[right]; |
| 233 return max - v <= v - min ? max : min; |
| 234 } |
| 235 |
| 236 static clamp(num, min, max) { |
| 237 return Math.min(Math.max(num, min), max); |
| 238 } |
| 239 |
| 240 static now() { |
| 241 return Date.now() / 1000; // performance.now() / 1000 |
| 242 } |
| 243 } |
| 244 |
| 245 var controllerSingleton; |
| 246 /* Animation controller is responsible to controlling all active snap animatio
ns and properly abort or resume them upon user touch. */ |
| 247 class AnimationController { |
| 248 |
| 249 static instance() { |
| 250 if (!controllerSingleton) |
| 251 controllerSingleton = new AnimationController(); |
| 252 |
| 253 return controllerSingleton; |
| 254 } |
| 255 |
| 256 // TODO(majidvp): make this private |
| 257 constructor() { |
| 258 this.active = new Map(); |
| 259 } |
| 260 |
| 261 add(snapper, property, animation) { |
| 262 if (!this.active.has(snapper)) |
| 263 this.active.set(snapper, {}); |
| 264 |
| 265 let snapAnimations = this.active.get(snapper) |
| 266 snapAnimations[property] = animation; |
| 267 |
| 268 log('start animation'); |
| 269 snapper.element.addEventListener('touchstart', AnimationController.onTouch
Start); |
| 270 snapper.element.addEventListener('touchend', AnimationController.onTouchEn
d); |
| 271 |
| 272 animation.start(); |
| 273 } |
| 274 |
| 275 remove(snapper) { |
| 276 let snapAnimations = this.active.get(snapper); |
| 277 if (snapAnimations) |
| 278 for (let key in snapAnimations) |
| 279 snapAnimations[key].stop(); |
| 280 this.active.delete(snapper); |
| 281 |
| 282 snapper.isOnhold = false; |
| 283 snapper.element.removeEventListener('touchstart', AnimationController.onTo
uchStart); |
| 284 snapper.element.removeEventListener('touchend', AnimationController.onTouc
hEnd); |
| 285 } |
| 286 |
| 287 // aborts snap animation for snapper and puts it on hold |
| 288 abort(snapper) { |
| 289 let snapAnimations = this.active.get(snapper); |
| 290 if (!snapAnimations || snapAnimations.length == 0) |
| 291 return; |
| 292 |
| 293 snapper.clearSnapState(); |
| 294 snapper.isOnhold = true; |
| 295 for (let key in snapAnimations) |
| 296 snapAnimations[key].stop(); |
| 297 |
| 298 this.active.delete(snapper); |
| 299 } |
| 300 |
| 301 // resume any aborted snap animation for this snapper |
| 302 resume(snapper) { |
| 303 if (!snapper || !snapper.isOnhold) |
| 304 return; |
| 305 // resuming snap animation is equivalent to snapping at the end of a scrol
l gesture with zero delta, and velocity |
| 306 snapper.snap(new ScrollState(0, 0, 0, 0, 0, false, false, true)); |
| 307 } |
| 308 |
| 309 static onTouchStart() { |
| 310 log("touchstart: abort snap animations"); |
| 311 AnimationController.instance().abort(this[snapper]); |
| 312 } |
| 313 |
| 314 static onTouchEnd() { |
| 315 log("touchend: re-snap"); |
| 316 AnimationController.instance().resume(this[snapper]); |
| 317 } |
| 318 } |
| 319 |
| 320 class Animation { |
| 321 /* |
| 322 * Setup necessary RAF loop for snap animation to reach snap destination |
| 323 */ |
| 324 constructor(params) { |
| 325 for (let attr of['element', 'property', 'begin', 'end', 'duration', 'dista
nce']) |
| 326 this[attr] = params[attr]; |
| 327 |
| 328 this.distance = this.end - this.begin; |
| 329 switch (params.curveType) { |
| 330 case 'motion': |
| 331 this.curve = SnapCurve.motion(-1 * params.v0, this.distance); |
| 332 break; |
| 333 case 'linear': |
| 334 default: |
| 335 this.curve = SnapCurve.linear(this.distance); |
| 336 } |
| 337 |
| 338 // bind step |
| 339 this.step = this.step.bind(this); |
| 340 } |
| 341 |
| 342 start() { |
| 343 log('Animate to scroll position ' + this.end + ' in ' + this.duration + '
ms'); |
| 344 if (this.duration <= 0) { |
| 345 log("WARNING: duration must be positive"); |
| 346 return; |
| 347 } |
| 348 |
| 349 this.startTime = undefined; |
| 350 this.rAF = window.requestAnimationFrame(this.step); |
| 351 } |
| 352 |
| 353 stop() { |
| 354 window.cancelAnimationFrame(this.rAF); |
| 355 } |
| 356 |
| 357 step(now) { |
| 358 log("animation step"); |
| 359 this.startTime = this.startTime || now; |
| 360 |
| 361 let progress = Math.min(now - this.startTime, this.duration) / this.durati
on; |
| 362 let delta = this.curve(progress); |
| 363 let next = Math.floor(this.begin + delta); |
| 364 log(this.property + '=' + next + ' (delta:' + delta + ' progress:' + prog
ress + ' now:' + now + ')'); |
| 365 |
| 366 this.element[this.property] = next; |
| 367 |
| 368 if (progress == 1) { |
| 369 this.stop(); |
| 370 // TODO: callback and let AnimationController know that this is complete |
| 371 return; |
| 372 } |
| 373 |
| 374 this.rAF = window.requestAnimationFrame(this.step); |
| 375 } |
| 376 |
| 377 } |
| 378 |
| 379 class SnapCurve { |
| 380 static linear(distance) { |
| 381 return function(progress) { |
| 382 return progress * distance; |
| 383 }; |
| 384 } |
| 385 |
| 386 /** |
| 387 * Constructs a motion curve D(t) which satisfies these conditions: |
| 388 * - D(0) = 0 and D(duration)= distance. |
| 389 * - Velocity (dD/dt) is continuous |
| 390 * - Velocity is v0 at start, t=0, and 0 at the end, t=1. |
| 391 */ |
| 392 static motion(v0, distance) { |
| 393 let a = 3 * v0 - 6 * distance, |
| 394 b = 6 * distance - 4 * v0; |
| 395 |
| 396 //let formula = '0.33*' + a + '*t^3+0.5*' + b + '*t^2+' + v0 + '*t' + ', y
=' + distance; |
| 397 //log('Motion Curve: ' + 'https://www.wolframalpha.com/input/?i=' + encode
URIComponent('plot ' + formula + ' for t=0 to 1')); |
| 398 return function curve(t) { |
| 399 // to ensure we always end up at distance at the end. |
| 400 if (t === 1) { |
| 401 return distance; |
| 402 } |
| 403 |
| 404 let t2 = t * t, |
| 405 t3 = t * t * t; |
| 406 return 0.33 * a * t3 + 0.5 * b * t2 + v0 * t; |
| 407 }; |
| 408 } |
| 409 } |
| 410 |
| 411 // From src/content/public/common/renderer_preferences.cc |
| 412 const p = [-5707.62, 172, 3.7]; |
| 413 |
| 414 // Times are in second |
| 415 class FlingCurve { |
| 416 constructor(initPosition, initVelocity, startTime) { |
| 417 |
| 418 this.sign = initVelocity >= 0 ? 1 : -1; |
| 419 |
| 420 initVelocity = Math.abs(initVelocity); |
| 421 initVelocity = Math.min(initVelocity, this.velocity(0)); |
| 422 initVelocity = Math.max(initVelocity, 0); |
| 423 |
| 424 this.localStartTime = this.timeAtVelocity(initVelocity); |
| 425 this.localEndTime = this.timeAtVelocity(0); |
| 426 this.endTime = startTime + this.localEndTime - this.localStartTime; |
| 427 this.positionOffset = initPosition - this.sign * this.position(this.localS
tartTime); |
| 428 this.timeOffset = this.localStartTime - startTime; |
| 429 |
| 430 // let formula = -p[0] + "*" + p[2] + "*" + " e^(" + (-p[2]) + " * t) + "
+ (-p[1]) + ", y=" + initial_velocity; |
| 431 //log('Fling Velocity Curve: ' + 'https://www.wolframalpha.com/input/?i='
+ encodeURIComponent('plot ' + formula + " for t=0 to 2")); |
| 432 //log('Duration:' + this.getDuration() * 1000 + " Distance:" + this.getDis
tance()); |
| 433 } |
| 434 |
| 435 // From src/content/child/touch_fling_gesture_curve.cc. |
| 436 // Uses local time and return local position |
| 437 position(localTime) { |
| 438 let t = localTime; |
| 439 return p[0] * Math.exp(-p[2] * t) - p[1] * t - p[0]; |
| 440 } |
| 441 |
| 442 velocity(localTime) { |
| 443 let t = localTime; |
| 444 return -p[0] * p[2] * Math.exp(-p[2] * t) - p[1]; |
| 445 } |
| 446 |
| 447 timeAtVelocity(velocity) { |
| 448 return (-Math.log((velocity + p[1]) / (-p[0] * p[2])) / p[2]); |
| 449 } |
| 450 |
| 451 // take global time and return global position |
| 452 getPositionAtTime(time) { |
| 453 time = Math.max(time, this.endTime); |
| 454 return this.positionOffset + this.sign * this.position(time + this.timeOff
set); |
| 455 }; |
| 456 |
| 457 getFinalPosition() { |
| 458 return this.positionOffset + this.sign * this.position(this.localEndTime); |
| 459 }; |
| 460 getDuration() { |
| 461 return this.localEndTime - this.localStartTime; |
| 462 }; |
| 463 getDistance() { |
| 464 return this.position(this.localEndTime) - this.position(this.localStartTim
e); |
| 465 } |
| 466 } |
| 467 |
| 468 // |
| 469 // Additions to the global |
| 470 // |
| 471 |
| 472 //%AddNamedProperty(global, 'SnapManager', SnapManager, DONT_ENUM); |
| 473 |
| 474 // |
| 475 // Exports for Blink to use |
| 476 // |
| 477 |
| 478 binding.CreateSnapManager = function(window) { |
| 479 return new SnapManager(window); |
| 480 } |
| 481 |
| 482 binding.UpdateSnapContainer = function(snapManager, element, horizontalOffsets
, verticalOffsets) { |
| 483 snapManager.updateSnapContainer(element, horizontalOffsets, verticalOffsets)
; |
| 484 } |
| 485 |
| 486 }); |
OLD | NEW |