Index: third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js |
diff --git a/third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js b/third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js |
new file mode 100644 |
index 0000000000000000000000000000000000000000..e977b13a04a1168412592a744af1e4494fd3faa1 |
--- /dev/null |
+++ b/third_party/WebKit/Source/core/page/scrolling/snap/SnapManager.js |
@@ -0,0 +1,486 @@ |
+// Copyright 2015 The Chromium Authors. All rights reserved. |
+// Use of this source code is governed by a BSD-style license that can be |
+// found in the LICENSE file. |
+ |
+(function(global, binding, v8) { |
+ 'use strict'; |
+ |
+ // V8 "Imports": |
+ // - %CreatePrivateOwnSymbol |
+ // - %_CallFunction |
+ // - %AddNamedProperty |
+ // - %HasOwnProperty |
+ // - $promiseThen, $promiseCreate, $promiseResolve, $promiseReject |
+ // - InternalPackedArray |
+ |
+ |
+ const Number = global.Number; |
+ const Math = global.Math; |
+ const Symbol = global.Symbol; |
+ const Date = global.Date; |
+ const Map = global.Map; |
+ // const performance = window.performance; |
+ // const console = window.console; |
+ |
+ const undefined = void 0; |
+ const DONT_ENUM = 2; |
+ const FLING_VELOCITY_THRESHOLD = 400; // dip/s |
+ |
+ var snapper = Symbol('[[Snapper]]'); |
+ |
+ // A reference to the window which is initialized by SnapManager constructor |
+ // This is needed because {request,cancel}AnimationFrame are not exposed in global |
+ let window; |
+ |
+ function log() { |
+ console.log.apply(console, arguments); |
+ var message = ""; |
+ for (var k in arguments) { |
+ message += arguments[k]; |
+ } |
+ |
+ //%DebugPrint(message); |
+ } |
+ |
+ /* |
+ * Responsible for receiving updates from Blink about changes to snap containers |
+ */ |
+ class SnapManager { |
+ constructor(win) { |
+ log('Initialized snap manager'); |
+ log(win); |
+ log(win.requestAnimationFrame); |
+ window = win; |
+ } |
+ |
+ updateSnapContainer(element, horizontalOffsets, verticalOffsets) { |
+ log(element); |
+ if (!element[snapper]) { |
+ // TODO(majidvp): I have put the snap related functionality in snap controller but perhaps |
+ // they can be in the elements prototype? |
+ element[snapper] = new Snapper(element); |
+ // Customize scrolling by override native scroll handler |
+ if (element.setApplyScroll) { |
+ element.setApplyScroll(element[snapper].applyScroll, "perform-before-native-scroll"); |
+ } else { |
+ log("Element::setApplyScroll is missing. Make sure ScrollCustomization feature is enabled."); |
+ } |
+ } |
+ |
+ element[snapper].setSnapOffsets(horizontalOffsets, verticalOffsets); |
+ } |
+ } |
+ |
+ /* Encapsulates the logic for determining when and where to snap for each snap container */ |
+ class Snapper { |
+ constructor(element) { |
+ this.element = element; |
+ // Make applyScroll bounded as it is called using function invocation pattern |
+ this.applyScroll = this.applyScroll.bind(this); |
+ } |
+ |
+ setSnapOffsets(h, v) { |
+ this.snapOffsets = [h, v]; |
+ // TODO: perhaps update inflight snaps? |
+ } |
+ |
+ // custom apply scroll handler |
+ applyScroll(scrollState) { |
+ log("scrollState", scrollState); |
+ if (scrollState.isBeginning) { |
+ // A new scroll clears all existing snap animations for this element |
+ this.clearSnapState(); |
+ AnimationController.instance().remove(this); |
+ return; |
+ } else if (!(scrollState.inInertialPhase || scrollState.isEnding)) { |
+ // Active user scrolling |
+ return; |
+ } |
+ let consumedDelta = this.snap(scrollState); |
+ scrollState.consumeDelta(consumedDelta[0], consumedDelta[1]); |
+ } |
+ |
+ |
+ // Make snapping decision separate on each dimension and creates an independent |
+ // 1D snap animations for each. This allows us to fling in one direction while |
+ // snap in another. |
+ snap(scrollState) { |
+ let consumedDelta = [0, 0]; |
+ |
+ for (let i of[0, 1]) { |
+ let property = i == 0 ? 'scrollLeft' : 'scrollTop'; |
+ let delta = i == 0 ? scrollState.deltaX : scrollState.deltaY; |
+ let velocity = i == 0 ? scrollState.velocityX : scrollState.velocityY; |
+ let position = this.scrollPosition()[i]; |
+ |
+ if (this.snapOffsets[i].length == 0) |
+ continue; |
+ |
+ // consume all scroll delta while snap is in progress preventing native scroll |
+ if (this.isSnapping[i]) { |
+ consumedDelta[i] = delta; |
+ continue; |
+ } |
+ |
+ // Wait for fling to slow down before starting the snap |
+ // TODO(majidvp): Handle overscroll cases as well. One option is to clamp the velocity when overscrolling |
+ if (scrollState.inInertialPhase && Math.abs(velocity) > FLING_VELOCITY_THRESHOLD) |
+ continue; |
+ |
+ if (scrollState.inInertialPhase && Math.abs(velocity) > 0 && !this.flingCurves[i]) |
+ this.flingCurves[i] = this.createFlingCurve(position, velocity); |
+ |
+ let td = this.snapTargetAndDuration(position, velocity, this.snapOffsets[i], this.flingCurves[i]), |
+ target = td.target, |
+ duration = td.duration; |
+ |
+ if (position == target) |
+ continue; |
+ |
+ // TODO(majidvp): Perhaps stay away from creating new animation objects to reduce |
+ // V8 GC pressure |
+ let animation = new Animation({ |
+ element: this.element, |
+ property: property, |
+ begin: position, |
+ end: target, |
+ duration: duration, |
+ v0: velocity, |
+ curveType: 'motion' /* linear | motion */ |
+ }); |
+ |
+ AnimationController.instance().add(this, property, animation); |
+ this.isSnapping[i] = true; |
+ log('horizontal:' + this.snapOffsets[0] + ' vertical:' + this.snapOffsets[1]); |
+ } |
+ |
+ return consumedDelta; |
+ } |
+ |
+ clearSnapState() { |
+ this.flingCurves = []; |
+ this.isSnapping = [false, false]; |
+ } |
+ |
+ createFlingCurve(position, velocity) { |
+ let now = Utils.now(); |
+ return new FlingCurve(position, -1 * velocity, now); |
+ } |
+ |
+ snapTargetAndDuration(position, velocity, snapOffsets, flingCurve) { |
+ let target = flingCurve ? flingCurve.getFinalPosition() : position; |
+ |
+ //let snapTarget = Utils.snapWithInterval(target, velocity, this.repeat(), size); |
+ let snapTarget = Utils.snapWithOffsets(target, velocity, snapOffsets); |
+ let snapDuration = this.estimateDuration(position, snapTarget, velocity, flingCurve); |
+ |
+ log('position:' + position + ' velocity:' + velocity + ' => target:' + target + ' snapped-target: ' + snapTarget + ' @(' + snapDuration + ')'); |
+ return { |
+ target: snapTarget, |
+ duration: snapDuration |
+ }; |
+ } |
+ |
+ estimateDuration(current, target, velocity, flingCurve) { |
+ // If there is a fling curve then use its estimated duration otherwise |
+ // assume a constant snap speed to calculate duration. |
+ let SPEED = 2; // 2px/ms |
+ return flingCurve ? flingCurve.getDuration() * 1000 : Math.abs(target - current) / SPEED; |
+ } |
+ |
+ scrollSize() { |
+ return [this.element.scrollWidth, this.element.scrollHeight]; |
+ } |
+ |
+ scrollPosition() { |
+ return [this.element.scrollLeft, this.element.scrollTop]; |
+ } |
+ } |
+ |
+ class Utils { |
+ static snapWithInterval(value, velocity, interval, max) { |
+ let roundingFn = velocity[i] == 0 ? Math.round : (velocity[i] > 0 ? Math.ceil : Math.floor); |
+ return Utils.clamp(roundingFn(value[i] / interval) * interval, 0, max[i]); |
+ } |
+ |
+ // Find closest match in values |
+ static snapWithOffsets(value, velocity, offsets) { |
+ let snappedOffset = Utils.binarySearchClosest(value, offsets); |
+ return snappedOffset; |
+ } |
+ |
+ // Find the value in array that is closes to v |
+ static binarySearchClosest(v, array) { |
+ if (array.length == 0) |
+ return v; |
+ |
+ let left = 0, |
+ right = array.length; |
+ // Clamp value to ensure it falls in range range |
+ v = Utils.clamp(v, array[0], array[right - 1]); |
+ |
+ while (right - left > 1) { |
+ let mid = Math.ceil((right + left) / 2); |
+ if (v < array[mid]) |
+ right = mid; |
+ else |
+ left = mid; |
+ } |
+ |
+ // min <= v <= max |
+ let min = array[left], |
+ max = array[right]; |
+ return max - v <= v - min ? max : min; |
+ } |
+ |
+ static clamp(num, min, max) { |
+ return Math.min(Math.max(num, min), max); |
+ } |
+ |
+ static now() { |
+ return Date.now() / 1000; // performance.now() / 1000 |
+ } |
+ } |
+ |
+ var controllerSingleton; |
+ /* Animation controller is responsible to controlling all active snap animations and properly abort or resume them upon user touch. */ |
+ class AnimationController { |
+ |
+ static instance() { |
+ if (!controllerSingleton) |
+ controllerSingleton = new AnimationController(); |
+ |
+ return controllerSingleton; |
+ } |
+ |
+ // TODO(majidvp): make this private |
+ constructor() { |
+ this.active = new Map(); |
+ } |
+ |
+ add(snapper, property, animation) { |
+ if (!this.active.has(snapper)) |
+ this.active.set(snapper, {}); |
+ |
+ let snapAnimations = this.active.get(snapper) |
+ snapAnimations[property] = animation; |
+ |
+ log('start animation'); |
+ snapper.element.addEventListener('touchstart', AnimationController.onTouchStart); |
+ snapper.element.addEventListener('touchend', AnimationController.onTouchEnd); |
+ |
+ animation.start(); |
+ } |
+ |
+ remove(snapper) { |
+ let snapAnimations = this.active.get(snapper); |
+ if (snapAnimations) |
+ for (let key in snapAnimations) |
+ snapAnimations[key].stop(); |
+ this.active.delete(snapper); |
+ |
+ snapper.isOnhold = false; |
+ snapper.element.removeEventListener('touchstart', AnimationController.onTouchStart); |
+ snapper.element.removeEventListener('touchend', AnimationController.onTouchEnd); |
+ } |
+ |
+ // aborts snap animation for snapper and puts it on hold |
+ abort(snapper) { |
+ let snapAnimations = this.active.get(snapper); |
+ if (!snapAnimations || snapAnimations.length == 0) |
+ return; |
+ |
+ snapper.clearSnapState(); |
+ snapper.isOnhold = true; |
+ for (let key in snapAnimations) |
+ snapAnimations[key].stop(); |
+ |
+ this.active.delete(snapper); |
+ } |
+ |
+ // resume any aborted snap animation for this snapper |
+ resume(snapper) { |
+ if (!snapper || !snapper.isOnhold) |
+ return; |
+ // resuming snap animation is equivalent to snapping at the end of a scroll gesture with zero delta, and velocity |
+ snapper.snap(new ScrollState(0, 0, 0, 0, 0, false, false, true)); |
+ } |
+ |
+ static onTouchStart() { |
+ log("touchstart: abort snap animations"); |
+ AnimationController.instance().abort(this[snapper]); |
+ } |
+ |
+ static onTouchEnd() { |
+ log("touchend: re-snap"); |
+ AnimationController.instance().resume(this[snapper]); |
+ } |
+ } |
+ |
+ class Animation { |
+ /* |
+ * Setup necessary RAF loop for snap animation to reach snap destination |
+ */ |
+ constructor(params) { |
+ for (let attr of['element', 'property', 'begin', 'end', 'duration', 'distance']) |
+ this[attr] = params[attr]; |
+ |
+ this.distance = this.end - this.begin; |
+ switch (params.curveType) { |
+ case 'motion': |
+ this.curve = SnapCurve.motion(-1 * params.v0, this.distance); |
+ break; |
+ case 'linear': |
+ default: |
+ this.curve = SnapCurve.linear(this.distance); |
+ } |
+ |
+ // bind step |
+ this.step = this.step.bind(this); |
+ } |
+ |
+ start() { |
+ log('Animate to scroll position ' + this.end + ' in ' + this.duration + ' ms'); |
+ if (this.duration <= 0) { |
+ log("WARNING: duration must be positive"); |
+ return; |
+ } |
+ |
+ this.startTime = undefined; |
+ this.rAF = window.requestAnimationFrame(this.step); |
+ } |
+ |
+ stop() { |
+ window.cancelAnimationFrame(this.rAF); |
+ } |
+ |
+ step(now) { |
+ log("animation step"); |
+ this.startTime = this.startTime || now; |
+ |
+ let progress = Math.min(now - this.startTime, this.duration) / this.duration; |
+ let delta = this.curve(progress); |
+ let next = Math.floor(this.begin + delta); |
+ log(this.property + '=' + next + ' (delta:' + delta + ' progress:' + progress + ' now:' + now + ')'); |
+ |
+ this.element[this.property] = next; |
+ |
+ if (progress == 1) { |
+ this.stop(); |
+ // TODO: callback and let AnimationController know that this is complete |
+ return; |
+ } |
+ |
+ this.rAF = window.requestAnimationFrame(this.step); |
+ } |
+ |
+ } |
+ |
+ class SnapCurve { |
+ static linear(distance) { |
+ return function(progress) { |
+ return progress * distance; |
+ }; |
+ } |
+ |
+ /** |
+ * Constructs a motion curve D(t) which satisfies these conditions: |
+ * - D(0) = 0 and D(duration)= distance. |
+ * - Velocity (dD/dt) is continuous |
+ * - Velocity is v0 at start, t=0, and 0 at the end, t=1. |
+ */ |
+ static motion(v0, distance) { |
+ let a = 3 * v0 - 6 * distance, |
+ b = 6 * distance - 4 * v0; |
+ |
+ //let formula = '0.33*' + a + '*t^3+0.5*' + b + '*t^2+' + v0 + '*t' + ', y=' + distance; |
+ //log('Motion Curve: ' + 'https://www.wolframalpha.com/input/?i=' + encodeURIComponent('plot ' + formula + ' for t=0 to 1')); |
+ return function curve(t) { |
+ // to ensure we always end up at distance at the end. |
+ if (t === 1) { |
+ return distance; |
+ } |
+ |
+ let t2 = t * t, |
+ t3 = t * t * t; |
+ return 0.33 * a * t3 + 0.5 * b * t2 + v0 * t; |
+ }; |
+ } |
+ } |
+ |
+ // From src/content/public/common/renderer_preferences.cc |
+ const p = [-5707.62, 172, 3.7]; |
+ |
+ // Times are in second |
+ class FlingCurve { |
+ constructor(initPosition, initVelocity, startTime) { |
+ |
+ this.sign = initVelocity >= 0 ? 1 : -1; |
+ |
+ initVelocity = Math.abs(initVelocity); |
+ initVelocity = Math.min(initVelocity, this.velocity(0)); |
+ initVelocity = Math.max(initVelocity, 0); |
+ |
+ this.localStartTime = this.timeAtVelocity(initVelocity); |
+ this.localEndTime = this.timeAtVelocity(0); |
+ this.endTime = startTime + this.localEndTime - this.localStartTime; |
+ this.positionOffset = initPosition - this.sign * this.position(this.localStartTime); |
+ this.timeOffset = this.localStartTime - startTime; |
+ |
+ // let formula = -p[0] + "*" + p[2] + "*" + " e^(" + (-p[2]) + " * t) + " + (-p[1]) + ", y=" + initial_velocity; |
+ //log('Fling Velocity Curve: ' + 'https://www.wolframalpha.com/input/?i=' + encodeURIComponent('plot ' + formula + " for t=0 to 2")); |
+ //log('Duration:' + this.getDuration() * 1000 + " Distance:" + this.getDistance()); |
+ } |
+ |
+ // From src/content/child/touch_fling_gesture_curve.cc. |
+ // Uses local time and return local position |
+ position(localTime) { |
+ let t = localTime; |
+ return p[0] * Math.exp(-p[2] * t) - p[1] * t - p[0]; |
+ } |
+ |
+ velocity(localTime) { |
+ let t = localTime; |
+ return -p[0] * p[2] * Math.exp(-p[2] * t) - p[1]; |
+ } |
+ |
+ timeAtVelocity(velocity) { |
+ return (-Math.log((velocity + p[1]) / (-p[0] * p[2])) / p[2]); |
+ } |
+ |
+ // take global time and return global position |
+ getPositionAtTime(time) { |
+ time = Math.max(time, this.endTime); |
+ return this.positionOffset + this.sign * this.position(time + this.timeOffset); |
+ }; |
+ |
+ getFinalPosition() { |
+ return this.positionOffset + this.sign * this.position(this.localEndTime); |
+ }; |
+ getDuration() { |
+ return this.localEndTime - this.localStartTime; |
+ }; |
+ getDistance() { |
+ return this.position(this.localEndTime) - this.position(this.localStartTime); |
+ } |
+ } |
+ |
+ // |
+ // Additions to the global |
+ // |
+ |
+ //%AddNamedProperty(global, 'SnapManager', SnapManager, DONT_ENUM); |
+ |
+ // |
+ // Exports for Blink to use |
+ // |
+ |
+ binding.CreateSnapManager = function(window) { |
+ return new SnapManager(window); |
+ } |
+ |
+ binding.UpdateSnapContainer = function(snapManager, element, horizontalOffsets, verticalOffsets) { |
+ snapManager.updateSnapContainer(element, horizontalOffsets, verticalOffsets); |
+ } |
+ |
+}); |