| 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);
|
| + }
|
| +
|
| +});
|
|
|