in Developer Console will enable the experiment for your browser.
+
+
+
+
Scrollbound animations with amp-position-observer and amp-animation
+
The following animation does one round (12pm-6pm) via scrolling when fully visible between the middle 80% of the viewport
+
+
+
+
+
+
+
+
+
+
+
+
+
Custom visibility start-end with amp-position-observer and amp-animation
+
The following animation will start when the scene is 50% visible and pauses when 50% invisible
+
+
+
+
+
+
+
+
+
+
+
+
Custom visibility end with amp-position-observer and amp-video
+
If played, the following video will pause when 20% invisible
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/animations.amp.html b/examples/animations.amp.html
index da262ed6f0cb..270919b2b030 100644
--- a/examples/animations.amp.html
+++ b/examples/animations.amp.html
@@ -178,7 +178,8 @@
-
+
+
diff --git a/examples/img/clock.jpg b/examples/img/clock.jpg
new file mode 100644
index 000000000000..ebb05d03d4a0
Binary files /dev/null and b/examples/img/clock.jpg differ
diff --git a/extensions/amp-animation/0.1/amp-animation.js b/extensions/amp-animation/0.1/amp-animation.js
index 23b8274b23fc..c1d290fa1c11 100644
--- a/extensions/amp-animation/0.1/amp-animation.js
+++ b/extensions/amp-animation/0.1/amp-animation.js
@@ -15,21 +15,22 @@
*/
import {Builder} from './web-animations';
-import {ScrollboundScene} from './scrollbound-scene';
+import {ActionTrust} from '../../../src/action-trust';
import {Pass} from '../../../src/pass';
import {WebAnimationPlayState} from './web-animation-types';
import {childElementByTag} from '../../../src/dom';
import {getFriendlyIframeEmbedOptional}
from '../../../src/friendly-iframe-embed';
-import {getMode} from '../../../src/mode';
import {getParentWindowFrameElement} from '../../../src/service';
import {isExperimentOn} from '../../../src/experiments';
import {installWebAnimations} from 'web-animations-js/web-animations.install';
import {listen} from '../../../src/event-helper';
import {setStyles} from '../../../src/style';
import {tryParseJson} from '../../../src/json';
-import {user, dev} from '../../../src/log';
+import {user} from '../../../src/log';
import {Services} from '../../../src/services';
+import {isFiniteNumber} from '../../../src/types';
+import {clamp} from '../../../src/utils/math';
const TAG = 'amp-animation';
const POLYFILLED = '__AMP_WA';
@@ -47,6 +48,9 @@ export class AmpAnimation extends AMP.BaseElement {
/** @private {boolean} */
this.visible_ = false;
+ /** @private {boolean} */
+ this.pausedByAction_ = false;
+
/** @private {boolean} */
this.triggered_ = false;
@@ -59,6 +63,9 @@ export class AmpAnimation extends AMP.BaseElement {
/** @private {?./web-animations.WebAnimationRunner} */
this.runner_ = null;
+ /** @private {?Promise} */
+ this.runnerPromise_ = null;
+
/** @private {?Pass} */
this.restartPass_ = null;
}
@@ -109,7 +116,11 @@ export class AmpAnimation extends AMP.BaseElement {
// Restart with debounce.
this.restartPass_ = new Pass(
this.win,
- this.startOrResume_.bind(this),
+ () => {
+ if (!this.pausedByAction_) {
+ this.startOrResume_();
+ }
+ },
/* delay */ 50);
// Visibility.
@@ -138,15 +149,24 @@ export class AmpAnimation extends AMP.BaseElement {
}
// Actions.
- this.registerAction('start', this.startAction_.bind(this));
- this.registerAction('restart', this.restartAction_.bind(this));
- this.registerAction('pause', this.pauseAction_.bind(this));
- this.registerAction('resume', this.resumeAction_.bind(this));
- this.registerAction('togglePause', this.togglePauseAction_.bind(this));
- this.registerAction('seekTo', this.seekToAction_.bind(this));
- this.registerAction('reverse', this.reverseAction_.bind(this));
- this.registerAction('finish', this.finishAction_.bind(this));
- this.registerAction('cancel', this.cancelAction_.bind(this));
+ this.registerAction('start',
+ this.startAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('restart',
+ this.restartAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('pause',
+ this.pauseAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('resume',
+ this.resumeAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('togglePause',
+ this.togglePauseAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('seekTo',
+ this.seekToAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('reverse',
+ this.reverseAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('finish',
+ this.finishAction_.bind(this), ActionTrust.LOW);
+ this.registerAction('cancel',
+ this.cancelAction_.bind(this), ActionTrust.LOW);
}
/**
@@ -172,11 +192,12 @@ export class AmpAnimation extends AMP.BaseElement {
/** @override */
activate(invocation) {
- this.startAction_(invocation);
+ return this.startAction_(invocation);
}
/**
* @param {?../../../src/service/action-impl.ActionInvocation=} opt_invocation
+ * @return {?Promise}
* @private
*/
startAction_(opt_invocation) {
@@ -184,12 +205,14 @@ export class AmpAnimation extends AMP.BaseElement {
// will actually be running.
this.triggered_ = true;
if (this.visible_) {
- this.startOrResume_(opt_invocation ? opt_invocation.args : null);
+ return this.startOrResume_(opt_invocation ? opt_invocation.args : null);
}
+ return Promise.resolve();
}
/**
* @param {!../../../src/service/action-impl.ActionInvocation} invocation
+ * @return {?Promise}
* @private
*/
restartAction_(invocation) {
@@ -198,61 +221,117 @@ export class AmpAnimation extends AMP.BaseElement {
// will actually be running.
this.triggered_ = true;
if (this.visible_) {
- this.startOrResume_(invocation.args);
+ return this.startOrResume_(invocation.args);
}
+ return Promise.resolve();
}
- /** @private */
+ /**
+ * @return {?Promise}
+ * @private
+ */
pauseAction_() {
- this.pause_();
+ if (!this.triggered_) {
+ return Promise.resolve();
+ }
+ return this.createRunnerIfNeeded_().then(() => {
+ this.pause_();
+ this.pausedByAction_ = true;
+ });
}
- /** @private */
+ /**
+ * @return {?Promise}
+ * @private
+ */
resumeAction_() {
- if (this.runner_ && this.visible_ && this.triggered_) {
- this.runner_.resume();
+ if (!this.triggered_) {
+ return Promise.resolve();
}
+ return this.createRunnerIfNeeded_().then(() => {
+ if (this.visible_) {
+ this.runner_.resume();
+ this.pausedByAction_ = false;
+ }
+ });
}
- /** @private */
+ /**
+ * @return {?Promise}
+ * @private
+ */
togglePauseAction_() {
- if (this.runner_ && this.visible_ && this.triggered_) {
- if (this.runner_.getPlayState() == WebAnimationPlayState.PAUSED) {
- this.startOrResume_();
- } else {
- this.pause_();
- }
+ if (!this.triggered_) {
+ return Promise.resolve();
}
+ return this.createRunnerIfNeeded_().then(() => {
+ if (this.visible_) {
+ if (this.runner_.getPlayState() == WebAnimationPlayState.PAUSED) {
+ return this.startOrResume_();
+ } else {
+ this.pause_();
+ this.pausedByAction_ = true;
+ }
+ }
+ });
}
/**
* @param {!../../../src/service/action-impl.ActionInvocation} invocation
+ * @return {?Promise}
* @private
*/
seekToAction_(invocation) {
- if (this.runner_ && this.visible_ && this.triggered_) {
+ // The animation will be triggered (in paused state) and seek will happen
+ // regardless of visibility
+ this.triggered_ = true;
+ return this.createRunnerIfNeeded_().then(() => {
+ this.pause_();
+ this.pausedByAction_ = true;
+ // time based seek
const time = parseFloat(invocation.args && invocation.args['time']);
- if (time && isFinite(time)) {
+ if (isFiniteNumber(time)) {
this.runner_.seekTo(time);
}
- }
+ // percent based seek
+ const percent = parseFloat(invocation.args && invocation.args['percent']);
+ if (isFiniteNumber(percent)) {
+ this.runner_.seekToPercent(clamp(percent, 0, 1));
+ }
+ });
}
- /** @private */
+ /**
+ * @return {?Promise}
+ * @private
+ */
reverseAction_() {
- if (this.runner_ && this.visible_ && this.triggered_) {
- this.runner_.reverse();
+ if (!this.triggered_) {
+ return Promise.resolve();
}
+ return this.createRunnerIfNeeded_().then(() => {
+ if (this.visible_) {
+ this.runner_.reverse();
+ }
+ });
}
- /** @private */
+ /**
+ * @return {?Promise}
+ * @private
+ */
finishAction_() {
this.finish_();
+ return Promise.resolve();
}
- /** @private */
+ /**
+ * @return {?Promise}
+ * @private
+ */
cancelAction_() {
this.cancel_();
+ return Promise.resolve();
}
/**
@@ -264,7 +343,9 @@ export class AmpAnimation extends AMP.BaseElement {
this.visible_ = visible;
if (this.triggered_) {
if (this.visible_) {
- this.startOrResume_();
+ if (!this.pausedByAction_) {
+ this.startOrResume_();
+ }
} else {
this.pause_();
}
@@ -274,18 +355,22 @@ export class AmpAnimation extends AMP.BaseElement {
/** @private */
onResize_() {
- // Store the previous `triggered` value since `cancel` may reset it.
+ // Store the previous `triggered` and `pausedByAction` value since
+ // `cancel` may reset it.
const triggered = this.triggered_;
+ const pausedByAction = this.pausedByAction_;
// Stop animation right away.
if (this.runner_) {
this.runner_.cancel();
this.runner_ = null;
+ this.runnerPromise_ = null;
}
// Restart the animation, but debounce to avoid re-starting it multiple
// times per restart.
this.triggered_ = triggered;
+ this.pausedByAction_ = pausedByAction;
if (this.triggered_ && this.visible_) {
this.restartPass_.schedule();
}
@@ -301,34 +386,55 @@ export class AmpAnimation extends AMP.BaseElement {
return null;
}
+ this.pausedByAction_ = false;
+
if (this.runner_) {
this.runner_.resume();
return null;
}
- return this.createRunner_(opt_args).then(runner => {
- this.runner_ = runner;
- this.runner_.onPlayStateChanged(this.playStateChanged_.bind(this));
- this.setupScrollboundAnimations_();
+ return this.createRunnerIfNeeded_(opt_args).then(() => {
this.runner_.start();
});
}
+ /**
+ * Creates the runner but animations will not start.
+ * @param {?JsonObject=} opt_args
+ * @return {!Promise}
+ * @private
+ */
+ createRunnerIfNeeded_(opt_args) {
+ if (!this.runnerPromise_) {
+ this.runnerPromise_ = this.createRunner_(opt_args).then(runner => {
+ this.runner_ = runner;
+ this.runner_.onPlayStateChanged(this.playStateChanged_.bind(this));
+ this.runner_.init();
+ });
+ }
+
+ return this.runnerPromise_;
+ }
+
/** @private */
finish_() {
this.triggered_ = false;
+ this.pausedByAction_ = false;
if (this.runner_) {
this.runner_.finish();
this.runner_ = null;
+ this.runnerPromise_ = null;
}
}
/** @private */
cancel_() {
this.triggered_ = false;
+ this.pausedByAction_ = false;
if (this.runner_) {
this.runner_.cancel();
this.runner_ = null;
+ this.runnerPromise_ = null;
}
}
@@ -390,36 +496,6 @@ export class AmpAnimation extends AMP.BaseElement {
this.finish_();
}
}
-
- /**
- * @private
- */
- setupScrollboundAnimations_() {
- dev().assert(this.runner_);
- if (!this.runner_.hasScrollboundAnimations()) {
- return;
- }
-
- // TODO(aghassemi): Remove restriction when we fully support scenes through
- // scene-id attribute and/or allowing parent of `amp-animation` to be the
- // scene container.
- user().assert(this.embed_ || getMode().runtime == 'inabox',
- 'scroll-bound animations are only supported in embeds at the moment');
-
- let sceneElement;
- if (this.embed_) {
- sceneElement = this.embed_.iframe;
- } else {
- sceneElement = this.win.document.documentElement;
- }
-
- new ScrollboundScene(
- this.getAmpDoc(),
- sceneElement,
- this.runner_.scrollTick.bind(this.runner_), /* onScroll */
- this.runner_.updateScrollDuration.bind(this.runner_) /* onDurationChanged */
- );
- }
}
/**
diff --git a/extensions/amp-animation/0.1/scrollbound-player.js b/extensions/amp-animation/0.1/scrollbound-player.js
deleted file mode 100644
index db152a1355df..000000000000
--- a/extensions/amp-animation/0.1/scrollbound-player.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Copyright 2016 The AMP HTML Authors. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS-IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-
-export class ScrollboundPlayer {
-
- /** @param {!./web-animations.InternalWebAnimationRequestDef} request */
- constructor(request) {
- // TODO(aghassemi): Use {Animation} as typedef but looks like it is missing
- // currentTime in the extern.
- /** @private {?Object} */
- this.animation_ = null;
-
- /** @private {!./web-animations.InternalWebAnimationRequestDef} */
- this.request_ = request;
-
- /** @private {!boolean} */
- this.paused_ = false;
-
- // If no duration, wait until duration arrives via onScrollDurationChanged
- if (request.timing.duration == 0) {
- return;
- } else {
- this.createAnimation_();
- }
- }
-
- onScrollDurationChanged() {
- let currentTime = 0;
- if (this.animation_) {
- currentTime = this.animation_.currentTime;
- }
- // we have to recreate the animation to change its duration
- this.createAnimation_();
- this.animation_.currentTime = currentTime;
- }
-
- pause() {
- this.paused_ = true;
- }
-
- play() {
- this.paused_ = false;
- }
-
- cancel() {
- this.paused_ = true;
- this.animation_.cancel();
- }
-
- tick(pos) {
- if (this.paused_ || !this.animation_) {
- return;
- }
- this.animation_.currentTime = pos;
- }
-
- createAnimation_() {
- this.animation_ = this.request_.target.animate(
- this.request_.keyframes, this.request_.timing);
- this.animation_.pause();
- }
-}
diff --git a/extensions/amp-animation/0.1/scrollbound-scene.js b/extensions/amp-animation/0.1/scrollbound-scene.js
deleted file mode 100644
index 3670e4b44a57..000000000000
--- a/extensions/amp-animation/0.1/scrollbound-scene.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * Copyright 2016 The AMP HTML Authors. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS-IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {
- installPositionObserverServiceForDoc,
- PositionObserverFidelity,
-} from '../../../src/service/position-observer-impl';
-import {getServiceForDoc} from '../../../src/service';
-
-export class ScrollboundScene {
-
- /**
- * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
- * @param {!Element} element
- * @param {!function(!number)} onScroll
- * @param {!Function} onDurationChanged
- */
- constructor(ampdoc, element, onScroll, onDurationChanged) {
-
- /** @private {!Element} */
- this.element_ = element;
-
- /** @private {?number} */
- this.scrollDuration_ = null;
-
- /** @private {!function(!number)} */
- this.onScroll_ = onScroll;
-
- /** @private {!Function} */
- this.onDurationChanged_ = onDurationChanged;
-
- installPositionObserverServiceForDoc(ampdoc);
- this.positionObserver_ = getServiceForDoc(ampdoc, 'position-observer');
-
- getServiceForDoc(ampdoc, 'position-observer').observe(
- this.element_,
- PositionObserverFidelity.HIGH,
- this.onPositionChanged_.bind(this)
- );
- }
-
- onPositionChanged_(newPos) {
- // Until we have visibility conditions exposed scroll duration is amount
- // from when element is fully visible until element is partially
- // invisible which is basically viewportHeight - elementHeight.
-
- const vpRect = newPos.viewportRect;
- const posRec = newPos.positionRect;
-
- // If no positionRect, it is fully outside of the viewport.
- if (!posRec) {
- return;
- }
-
- // Did scroll duration changed?
- const scrollDuration = vpRect.height - posRec.height;
- if (scrollDuration != this.scrollDuration_) {
- this.scrollDuration_ = scrollDuration;
- this.onDurationChanged_(scrollDuration);
- }
-
- // Is scene fully visible?
- const isFullyVisible = posRec.bottom <= vpRect.height && posRec.top >= 0;
-
- if (isFullyVisible) {
- this.onScroll_(vpRect.height - posRec.bottom);
- } else {
- // Send the final position
- if (posRec.bottom < vpRect.height) {
- this.onScroll_(scrollDuration);
- } else {
- this.onScroll_(0);
- }
- }
- }
-}
diff --git a/extensions/amp-animation/0.1/test/test-amp-animation.js b/extensions/amp-animation/0.1/test/test-amp-animation.js
index a37a703e7fd3..225bdcf80b43 100644
--- a/extensions/amp-animation/0.1/test/test-amp-animation.js
+++ b/extensions/amp-animation/0.1/test/test-amp-animation.js
@@ -15,11 +15,9 @@
*/
import {AmpAnimation} from '../amp-animation';
-import {Builder, WebAnimationRunner} from '../web-animations';
+import {WebAnimationRunner} from '../web-animations';
import {WebAnimationPlayState} from '../web-animation-types';
import {toggleExperiment} from '../../../../src/experiments';
-import * as sinon from 'sinon';
-
describes.sandboxed('AmpAnimation', {}, () => {
@@ -204,7 +202,7 @@ describes.sandboxed('AmpAnimation', {}, () => {
// Go to hidden state.
viewer.setVisibilityState_('hidden');
expect(pauseStub).to.be.calledOnce;
- expect(startStub).to.be.calledOnce; // Doesn't chnage.
+ expect(startStub).to.be.calledOnce; // Doesn't change.
});
it('should NOT resume/pause when visible, but not triggered', function* () {
@@ -224,6 +222,20 @@ describes.sandboxed('AmpAnimation', {}, () => {
expect(startStub).to.not.be.called;
});
+ it('should NOT resume when visible if paused by an action', function* () {
+ const anim = yield createAnim({trigger: 'visibility'}, {duration: 1001});
+ const startStub = sandbox.stub(anim, 'startOrResume_');
+ const pauseStub = sandbox.stub(anim, 'pause_');
+ anim.activate();
+ anim.pausedByAction_ = true;
+ expect(anim.triggered_).to.be.true;
+
+ // Go to visible state.
+ viewer.setVisibilityState_('visible');
+ expect(startStub).to.not.be.called;
+ expect(pauseStub).to.not.be.called;
+ });
+
it('should create runner', function* () {
const anim = yield createAnim({trigger: 'visibility'},
{duration: 1001, animations: []});
@@ -364,149 +376,250 @@ describes.sandboxed('AmpAnimation', {}, () => {
});
});
- it('should trigger activate via start', () => {
- const startStub = sandbox.stub(anim, 'startOrResume_');
+ it('should trigger activate', () => {
const args = {};
const invocation = {
method: 'activate',
args,
satisfiesTrust: () => true,
};
- anim.executeAction(invocation);
- expect(anim.triggered_).to.be.true;
- expect(startStub).to.be.calledOnce;
- expect(startStub).to.be.calledWith(args);
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.IDLE);
+ expect(anim.triggered_).to.be.false;
+
+ return anim.executeAction(invocation).then(() => {
+ expect(anim.triggered_).to.be.true;
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.RUNNING);
+ });
});
it('should trigger start', () => {
- const startStub = sandbox.stub(anim, 'startOrResume_');
const args = {};
const invocation = {
method: 'start',
args,
satisfiesTrust: () => true,
};
- anim.executeAction(invocation);
- expect(anim.triggered_).to.be.true;
- expect(startStub).to.be.calledOnce;
- expect(startStub).to.be.calledWith(args);
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.IDLE);
+ expect(anim.triggered_).to.be.false;
+
+ return anim.executeAction(invocation).then(() => {
+ expect(anim.triggered_).to.be.true;
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.RUNNING);
+ });
});
it('should create runner with args', () => {
+ const args = {foo: 'bar'};
+ const invocation = {
+ method: 'start',
+ args,
+ satisfiesTrust: () => true,
+ };
+
+ expect(createRunnerStub).not.to.be.called;
+
+ return anim.executeAction(invocation).then(() => {
+ expect(createRunnerStub).to.be.calledWith(args);
+ });
+ });
+
+ it('should trigger but not start if not visible', () => {
+ anim.visible_ = false;
const args = {};
- anim.triggered_ = true;
- createRunnerStub./*OK*/restore();
- const stub = sandbox.stub(Builder.prototype, 'createRunner',
- () => runner);
- return anim.startOrResume_(args).then(() => {
- expect(anim.runner_).to.exist;
- expect(stub).to.be.calledWith(sinon.match.any, args);
+ const invocation = {
+ method: 'start',
+ args,
+ satisfiesTrust: () => true,
+ };
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.IDLE);
+ expect(anim.triggered_).to.be.false;
+
+ return anim.executeAction(invocation).then(() => {
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.IDLE);
+ expect(anim.triggered_).to.be.true;
});
});
- it('should trigger restart via cancel and start', () => {
+ it('should trigger restart', () => {
const cancelStub = sandbox.stub(anim, 'cancel_');
- const startStub = sandbox.stub(anim, 'startOrResume_');
const args = {};
const invocation = {
method: 'restart',
args,
satisfiesTrust: () => true,
};
- anim.executeAction(invocation);
- expect(anim.triggered_).to.be.true;
- expect(cancelStub).to.be.calledOnce;
- expect(startStub).to.be.calledOnce;
- expect(startStub).to.be.calledWith(args);
+ return anim.executeAction(invocation).then(() => {
+ expect(anim.triggered_).to.be.true;
+ expect(cancelStub).to.be.calledOnce;
+ expect(anim.triggered_).to.be.true;
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.RUNNING);
+ });
});
it('should trigger pause after start', () => {
- anim.triggered_ = true;
- return anim.startOrResume_().then(() => {
- runnerMock.expects('pause').once();
- anim.executeAction({method: 'pause', satisfiesTrust: () => true});
+ const args = {};
+ const startInvocation = {
+ method: 'start',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const pauseInvocation = {
+ method: 'pause',
+ args,
+ satisfiesTrust: () => true,
+ };
+ anim.executeAction(startInvocation);
+ return anim.executeAction(pauseInvocation).then(() => {
+ expect(anim.triggered_).to.be.true;
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.PAUSED);
});
});
it('should ignore pause before start', () => {
runnerMock.expects('pause').never();
- anim.executeAction({method: 'pause', satisfiesTrust: () => true});
+ return anim.executeAction(
+ {method: 'pause', satisfiesTrust: () => true}
+ );
});
- it('should trigger resume after start', () => {
- anim.triggered_ = true;
- return anim.startOrResume_().then(() => {
- runnerMock.expects('resume').once();
- anim.executeAction({method: 'resume', satisfiesTrust: () => true});
+ it('should trigger resume after start follwed by pause', () => {
+ const args = {};
+ const startInvocation = {
+ method: 'start',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const pauseInvocation = {
+ method: 'pause',
+ args,
+ satisfiesTrust: () => true,
+ };
+ anim.executeAction(startInvocation);
+ return anim.executeAction(pauseInvocation).then(() => {
+ expect(anim.triggered_).to.be.true;
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.PAUSED);
+ const resumeInvocation = {
+ method: 'resume',
+ args,
+ satisfiesTrust: () => true,
+ };
+ return anim.executeAction(resumeInvocation);
+ }).then(() => {
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.RUNNING);
});
});
it('should ignore resume before start', () => {
runnerMock.expects('resume').never();
- anim.executeAction({method: 'resume', satisfiesTrust: () => true});
+ return anim.executeAction(
+ {method: 'resume', satisfiesTrust: () => true}
+ );
});
it('should toggle pause/resume after start', () => {
- anim.triggered_ = true;
- return anim.startOrResume_().then(() => {
- runnerMock.expects('pause').once();
- anim.executeAction({
- method: 'togglePause',
- satisfiesTrust: () => true,
- });
+ const args = {};
+ const startInvocation = {
+ method: 'start',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const togglePauseInvocation = {
+ method: 'togglePause',
+ args,
+ satisfiesTrust: () => true,
+ };
+ anim.executeAction(startInvocation);
+ return anim.executeAction(togglePauseInvocation).then(() => {
+ expect(anim.triggered_).to.be.true;
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.PAUSED);
+ return anim.executeAction(togglePauseInvocation);
+ }).then(() => {
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.RUNNING);
+ });
+ });
- runnerMock.expects('getPlayState')
- .returns(WebAnimationPlayState.PAUSED);
- runnerMock.expects('resume').once();
- anim.executeAction({
- method: 'togglePause',
- satisfiesTrust: () => true,
- });
+ it('should ignore toggle pause/resume before start', () => {
+ runnerMock.expects('resume').never();
+ runnerMock.expects('pause').never();
+ return anim.executeAction(
+ {method: 'togglePause', satisfiesTrust: () => true}
+ );
+ });
+
+ it('should seek-to (time) regardless of start', () => {
+ const invocation = {
+ method: 'seekTo',
+ args: {time: 100},
+ satisfiesTrust: () => true,
+ };
+
+ runnerMock.expects('seekTo').withExactArgs(100).once();
+ return anim.executeAction(invocation).then(() => {
+ expect(anim.triggered_).to.be.true;
});
});
- it('should seek-to after start', () => {
- anim.triggered_ = true;
- return anim.startOrResume_().then(() => {
- runnerMock.expects('seekTo').withExactArgs(100).once();
- let invocation = {
- method: 'seekTo',
- args: {time: 100},
- satisfiesTrust: () => true,
- };
- anim.executeAction(invocation);
+ it('should seek-to (percent) regardless of start', () => {
+ const invocation = {
+ method: 'seekTo',
+ args: {percent: 0.5},
+ satisfiesTrust: () => true,
+ };
- runnerMock.expects('seekTo').withExactArgs(200).once();
- invocation = {
- method: 'seekTo',
- args: {time: 200},
- satisfiesTrust: () => true,
- };
- anim.executeAction(invocation);
+ runnerMock.expects('seekToPercent').withExactArgs(0.5).once();
+ return anim.executeAction(invocation).then(() => {
+ expect(anim.triggered_).to.be.true;
});
});
- it('should ignore seek-to before start', () => {
- runnerMock.expects('seekTo').never();
+ it('should clamp percent (upper) seekTo', () => {
const invocation = {
method: 'seekTo',
- args: {time: 100},
+ args: {percent: 1.5},
satisfiesTrust: () => true,
};
- anim.executeAction(invocation);
+
+ runnerMock.expects('seekToPercent').withExactArgs(1).once();
+ return anim.executeAction(invocation).then(() => {
+ expect(anim.triggered_).to.be.true;
+ });
});
- it('should trigger reverse after start', () => {
- anim.triggered_ = true;
- return anim.startOrResume_().then(() => {
- runnerMock.expects('reverse').once();
- anim.executeAction({method: 'reverse', satisfiesTrust: () => true});
+ it('should clamp percent (lower) seekTo', () => {
+ const invocation = {
+ method: 'seekTo',
+ args: {percent: -2},
+ satisfiesTrust: () => true,
+ };
+
+ runnerMock.expects('seekToPercent').withExactArgs(0).once();
+ return anim.executeAction(invocation).then(() => {
+ expect(anim.triggered_).to.be.true;
});
});
+ it('should trigger reverse after start', () => {
+ const args = {};
+ const startInvocation = {
+ method: 'start',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const invocation = {
+ method: 'reverse',
+ args,
+ satisfiesTrust: () => true,
+ };
+ anim.executeAction(startInvocation);
+ runnerMock.expects('reverse').once();
+ return anim.executeAction(invocation);
+ });
+
it('should ignore reverse before start', () => {
runnerMock.expects('reverse').never();
- anim.executeAction({method: 'reverse', satisfiesTrust: () => true});
+ return anim.executeAction(
+ {method: 'reverse', satisfiesTrust: () => true}
+ );
});
it('should trigger finish after start', () => {
@@ -524,6 +637,74 @@ describes.sandboxed('AmpAnimation', {}, () => {
anim.executeAction({method: 'cancel', satisfiesTrust: () => true});
});
});
+
+ it('should set paused by action properly', () => {
+ const args = {};
+ const startInvocation = {
+ method: 'start',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const pauseInvocation = {
+ method: 'pause',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const resumeInvocation = {
+ method: 'resume',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const togglePauseInvocation = {
+ method: 'togglePause',
+ args,
+ satisfiesTrust: () => true,
+ };
+ const cancelInvocation = {
+ method: 'cancel',
+ args,
+ satisfiesTrust: () => true,
+ };
+ expect(anim.pausedByAction_).to.be.false;
+
+ return anim.executeAction(startInvocation).then(() => {
+ expect(anim.pausedByAction_).to.be.false;
+ return anim.executeAction(pauseInvocation);
+ }).then(() => {
+ expect(anim.pausedByAction_).to.be.true;
+ return anim.executeAction(resumeInvocation);
+ }).then(() => {
+ expect(anim.pausedByAction_).to.be.false;
+ return anim.executeAction(togglePauseInvocation);
+ }).then(() => {
+ expect(anim.pausedByAction_).to.be.true;
+ return anim.executeAction(cancelInvocation);
+ }).then(() => {
+ expect(anim.pausedByAction_).to.be.false;
+ });
+ });
+
+ it.skip('should set paused by action flag', () => {
+ anim.triggered_ = true;
+ return anim.startOrResume_().then(() => {
+ expect(anim.pausedByAction_).to.be.false;
+ let invocation = {
+ method: 'pause',
+ args: {},
+ satisfiesTrust: () => true,
+ };
+ anim.executeAction(invocation);
+ expect(anim.pausedByAction_).to.be.true;
+
+ invocation = {
+ method: 'resume',
+ args: {},
+ satisfiesTrust: () => true,
+ };
+ anim.executeAction(invocation);
+ expect(anim.pausedByAction_).to.be.false;
+ });
+ });
});
});
diff --git a/extensions/amp-animation/0.1/test/test-web-animations.js b/extensions/amp-animation/0.1/test/test-web-animations.js
index 27f5b2d91dab..9fb9c9056b21 100644
--- a/extensions/amp-animation/0.1/test/test-web-animations.js
+++ b/extensions/amp-animation/0.1/test/test-web-animations.js
@@ -244,7 +244,6 @@ describes.realWin('MeasureScanner', {amp: 1}, env => {
easing: 'ease-in',
direction: 'reverse',
fill: 'auto',
- ticker: 'time',
},
],
});
@@ -257,7 +256,6 @@ describes.realWin('MeasureScanner', {amp: 1}, env => {
easing: 'ease-in',
direction: 'reverse',
fill: 'auto',
- ticker: 'time',
});
});
@@ -1498,10 +1496,10 @@ describes.sandboxed('WebAnimationRunner', {}, () => {
class WebAnimationStub {
play() {
- throw new Error('not implemented');
+ return;
}
pause() {
- throw new Error('not implemented');
+ return;
}
reverse() {
throw new Error('not implemented');
@@ -1553,7 +1551,26 @@ describes.sandboxed('WebAnimationRunner', {}, () => {
return style;
}
- it('should call start on all animatons', () => {
+ it('should call init on all animations and stay in IDLE state', () => {
+ target1Mock.expects('animate')
+ .withExactArgs(keyframes1, timing1)
+ .returns(anim1)
+ .once();
+ target2Mock.expects('animate')
+ .withExactArgs(keyframes2, timing2)
+ .returns(anim2)
+ .once();
+
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.IDLE);
+ runner.init();
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.IDLE);
+ expect(runner.players_).to.have.length(2);
+ expect(runner.players_[0]).equal(anim1);
+ expect(runner.players_[1]).equal(anim2);
+ expect(playStateSpy).not.to.be.called;
+ });
+
+ it('should call start on all animations', () => {
target1Mock.expects('animate')
.withExactArgs(keyframes1, timing1)
.returns(anim1)
@@ -1573,10 +1590,10 @@ describes.sandboxed('WebAnimationRunner', {}, () => {
expect(playStateSpy.args[0][0]).to.equal(WebAnimationPlayState.RUNNING);
});
- it('should fail to start twice', () => {
- runner.start();
+ it('should fail to init twice', () => {
+ runner.init();
expect(() => {
- runner.start();
+ runner.init();
}).to.throw();
});
@@ -1707,4 +1724,214 @@ describes.sandboxed('WebAnimationRunner', {}, () => {
expect(anim1.currentTime).to.equal(101);
expect(anim2.currentTime).to.equal(101);
});
+
+ it('should seek percent all animations', () => {
+ runner.start();
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.RUNNING);
+
+ sandbox.stub(runner, 'getTotalDuration_').returns(500);
+ anim1Mock.expects('pause').once();
+ anim2Mock.expects('pause').once();
+ runner.seekToPercent(0.5);
+ expect(runner.getPlayState()).to.equal(WebAnimationPlayState.PAUSED);
+ expect(anim1.currentTime).to.equal(250);
+ expect(anim2.currentTime).to.equal(250);
+ });
+
+ describe('total duration', () => {
+ it('single request, 0 total', () => {
+ const timing = {
+ duration: 0,
+ delay: 0,
+ endDelay: 0,
+ iterations: 1,
+ iterationStart: 0,
+ };
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing},
+ ]);
+ expect(runner.getTotalDuration_()).to.equal(0);
+ });
+
+ it('single request, 0 iteration', () => {
+ const timing = {
+ duration: 100,
+ delay: 100,
+ endDelay: 100,
+ iterations: 0,
+ iterationStart: 0,
+ };
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing},
+ ]);
+
+ // 200 for delays
+ expect(runner.getTotalDuration_()).to.equal(200);
+ });
+
+ it('single request, 1 iteration', () => {
+ const timing = {
+ duration: 100,
+ delay: 100,
+ endDelay: 100,
+ iterations: 1,
+ iterationStart: 0,
+ };
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing},
+ ]);
+ expect(runner.getTotalDuration_()).to.equal(300);
+ });
+
+ it('single request, multiple iterations', () => {
+ const timing = {
+ duration: 100,
+ delay: 100,
+ endDelay: 100,
+ iterations: 3,
+ iterationStart: 0,
+ };
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing},
+ ]);
+ expect(runner.getTotalDuration_()).to.equal(500); // 3*100 + 100 + 100
+ });
+
+ it('single request, multiple iterations with iterationStart', () => {
+ const timing = {
+ duration: 100,
+ delay: 100,
+ endDelay: 100,
+ iterations: 3,
+ iterationStart: 2.5,
+ };
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing},
+ ]);
+ // iterationStart is 2.5, the first 2.5 out of 3 iterations are ignored.
+ expect(runner.getTotalDuration_()).to.equal(250);// 0.5*100 + 100 + 100
+ });
+
+ it('single request, infinite iteration', () => {
+ const timing = {
+ duration: 100,
+ delay: 100,
+ endDelay: 100,
+ iterations: 'infinity',
+ iterationStart: 0,
+ };
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing},
+ ]);
+ expect(() => runner.getTotalDuration_()).to.throw(/has infinite/);
+ });
+
+ it('multiple requests - 0 total', () => {
+ // 0 because iteration is 0
+ const timing1 = {
+ duration: 100,
+ delay: 0,
+ endDelay: 0,
+ iterations: 0,
+ iterationStart: 0,
+ };
+
+ // 0 because duration is 0
+ const timing2 = {
+ duration: 0,
+ delay: 0,
+ endDelay: 0,
+ iterations: 1,
+ iterationStart: 0,
+ };
+
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing: timing1},
+ {target: target1, keyframes: keyframes1, timing: timing2},
+ ]);
+
+ expect(runner.getTotalDuration_()).to.equal(0);
+ });
+
+ it('multiple requests - bigger by duration', () => {
+ // 300
+ const timing1 = {
+ duration: 100,
+ delay: 100,
+ endDelay: 100,
+ iterations: 1,
+ iterationStart: 0,
+ };
+
+ // 500 - bigger
+ const timing2 = {
+ duration: 300,
+ delay: 100,
+ endDelay: 100,
+ iterations: 1,
+ iterationStart: 0,
+ };
+
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing: timing1},
+ {target: target1, keyframes: keyframes1, timing: timing2},
+ ]);
+
+ expect(runner.getTotalDuration_()).to.equal(500);
+ });
+
+ it('multiple requests - bigger by iteration', () => {
+ // 800 - bigger
+ const timing1 = {
+ duration: 200,
+ delay: 100,
+ endDelay: 100,
+ iterations: 3,
+ iterationStart: 0,
+ };
+
+ // 500
+ const timing2 = {
+ duration: 300,
+ delay: 100,
+ endDelay: 100,
+ iterations: 1,
+ iterationStart: 0,
+ };
+
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing: timing1},
+ {target: target1, keyframes: keyframes1, timing: timing2},
+ ]);
+
+ expect(runner.getTotalDuration_()).to.equal(800);
+ });
+
+ it('multiple request, infinite iteration', () => {
+ const timing1 = {
+ duration: 100,
+ delay: 100,
+ endDelay: 100,
+ iterations: 'infinity',
+ iterationStart: 0,
+ };
+
+ // 500
+ const timing2 = {
+ duration: 300,
+ delay: 100,
+ endDelay: 100,
+ iterations: 1,
+ iterationStart: 0,
+ };
+
+ const runner = new WebAnimationRunner([
+ {target: target1, keyframes: keyframes1, timing: timing1},
+ {target: target1, keyframes: keyframes1, timing: timing2},
+ ]);
+
+ expect(() => runner.getTotalDuration_()).to.throw(/has infinite/);
+ });
+
+ });
});
diff --git a/extensions/amp-animation/0.1/web-animation-types.js b/extensions/amp-animation/0.1/web-animation-types.js
index e1a7552fc407..aff834e5942d 100644
--- a/extensions/amp-animation/0.1/web-animation-types.js
+++ b/extensions/amp-animation/0.1/web-animation-types.js
@@ -93,7 +93,6 @@ export let WebKeyframesDef;
* easing: (string|undefined),
* direction: (!WebAnimationTimingDirection|undefined),
* fill: (!WebAnimationTimingFill|undefined),
- * ticker: (string|undefined)
* }}
*/
export let WebAnimationTimingDef;
diff --git a/extensions/amp-animation/0.1/web-animations.js b/extensions/amp-animation/0.1/web-animations.js
index 6bba53c738af..8969d64dc213 100644
--- a/extensions/amp-animation/0.1/web-animations.js
+++ b/extensions/amp-animation/0.1/web-animations.js
@@ -16,7 +16,6 @@
import {CssNumberNode, CssTimeNode, isVarCss} from './css-expr-ast';
import {Observable} from '../../../src/observable';
-import {ScrollboundPlayer} from './scrollbound-player';
import {assertHttpsUrl, resolveRelativeUrl} from '../../../src/url';
import {closestBySelector, matches} from '../../../src/dom';
import {dev, user} from '../../../src/log';
@@ -69,14 +68,6 @@ let animIdCounter = 0;
*/
export let InternalWebAnimationRequestDef;
-/**
- * @private
- * @enum {string}
- */
-const Tickers = {
- SCROLL: 'scroll',
- TIME: 'time',
-};
/**
* @const {!Object}
@@ -127,10 +118,10 @@ export class WebAnimationRunner {
}
/**
+ * Initializes the players but does not change the state.
*/
- start() {
+ init() {
dev().assert(!this.players_);
- this.setPlayState_(WebAnimationPlayState.RUNNING);
this.players_ = this.requests_.map(request => {
// Apply vars.
if (request.vars) {
@@ -139,13 +130,8 @@ export class WebAnimationRunner {
}
}
- // Create the player.
- let player;
- if (request.timing.ticker == Tickers.SCROLL) {
- player = new ScrollboundPlayer(request);
- } else {
- player = request.target.animate(request.keyframes, request.timing);
- }
+ const player = request.target.animate(request.keyframes, request.timing);
+ player.pause();
return player;
});
this.runningCount_ = this.players_.length;
@@ -159,6 +145,17 @@ export class WebAnimationRunner {
});
}
+ /**
+ * Initializes the players if not already initialized,
+ * and starts playing the animations.
+ */
+ start() {
+ if (!this.players_) {
+ this.init();
+ }
+ this.resume();
+ }
+
/**
*/
pause() {
@@ -199,14 +196,22 @@ export class WebAnimationRunner {
this.setPlayState_(WebAnimationPlayState.PAUSED);
this.players_.forEach(player => {
player.pause();
- if (player instanceof ScrollboundPlayer) {
- player.tick(time);
- } else {
- player.currentTime = time;
- }
+ player.currentTime = time;
});
}
+ /**
+ * Seeks to a relative position within the animation timeline given a
+ * percentage (0 to 1 number).
+ * @param {number} percent between 0 and 1
+ */
+ seekToPercent(percent) {
+ dev().assert(percent >= 0 && percent <= 1);
+ const totalDuration = this.getTotalDuration_();
+ const time = totalDuration * percent;
+ this.seekTo(time);
+ }
+
/**
*/
finish() {
@@ -234,52 +239,39 @@ export class WebAnimationRunner {
}
/**
+ * @param {!WebAnimationPlayState} playState
+ * @private
*/
- scrollTick(pos) {
- this.players_.forEach(player => {
- if (player instanceof ScrollboundPlayer) {
- player.tick(pos);
- }
- });
+ setPlayState_(playState) {
+ if (this.playState_ != playState) {
+ this.playState_ = playState;
+ this.playStateChangedObservable_.fire(this.playState_);
+ }
}
/**
+ * @return {!number} total duration in milliseconds.
+ * @throws {Error} If timeline is infinite.
*/
- updateScrollDuration(newDuration) {
- this.requests_.forEach(request => {
- if (request.timing.ticker == Tickers.SCROLL) {
- request.timing.duration = newDuration;
- }
- });
+ getTotalDuration_() {
+ let maxTotalDuration = 0;
+ for (let i = 0; i < this.requests_.length; i++) {
+ const timing = this.requests_[i].timing;
- this.players_.forEach(player => {
- if (player instanceof ScrollboundPlayer) {
- player.onScrollDurationChanged();
- }
- });
- }
+ user().assert(isFinite(timing.iterations), 'Animation has infinite ' +
+ 'timeline, we can not seek to a relative position within an infinite ' +
+ 'timeline. Use "time" for seekTo or remove infinite iterations');
- /**
- */
- hasScrollboundAnimations() {
- for (let i = 0; i < this.requests_.length; i++) {
- if (this.requests_[i].timing.ticker == Tickers.SCROLL) {
- return true;
+ const iteration = timing.iterations - timing.iterationStart;
+ const totalDuration = (timing.duration * iteration) +
+ timing.delay + timing.endDelay;
+
+ if (totalDuration > maxTotalDuration) {
+ maxTotalDuration = totalDuration;
}
}
- return false;
- }
-
- /**
- * @param {!WebAnimationPlayState} playState
- * @private
- */
- setPlayState_(playState) {
- if (this.playState_ != playState) {
- this.playState_ = playState;
- this.playStateChangedObservable_.fire(this.playState_);
- }
+ return maxTotalDuration;
}
}
@@ -490,7 +482,6 @@ export class MeasureScanner extends Scanner {
/** @private {!WebAnimationTimingDef} */
this.timing_ = timing || {
- ticker: Tickers.TIME,
duration: 0,
delay: 0,
endDelay: 0,
@@ -602,6 +593,14 @@ export class MeasureScanner extends Scanner {
onKeyframeAnimation(spec) {
this.with_(spec, () => {
const target = user().assertElement(this.target_, 'No target specified');
+ //TODO(aghassemi,#10911): Remove this warning later.
+ if (spec && spec.ticker) {
+ user().error(TAG, 'Experimental `ticker` property has been removed. ' +
+ 'For scroll-bound animations, please see the new approach at ' +
+ 'https://github.com/ampproject/amphtml/blob/master/extensions/' +
+ 'amp-position-observer/amp-position-observer.md'
+ );
+ }
const keyframes = this.createKeyframes_(target, spec);
this.requests_.push({
target,
@@ -895,9 +894,6 @@ export class MeasureScanner extends Scanner {
const fill = /** @type {!WebAnimationTimingFill} */
(this.css_.resolveIdent(newTiming.fill, prevTiming.fill));
- // Other.
- const ticker = newTiming.ticker != null ?
- newTiming.ticker : prevTiming.ticker;
// Validate.
this.validateTime_(duration, newTiming.duration, 'duration');
@@ -921,7 +917,6 @@ export class MeasureScanner extends Scanner {
easing,
direction,
fill,
- ticker,
};
}
diff --git a/extensions/amp-animation/amp-animation.md b/extensions/amp-animation/amp-animation.md
index 3ae04a2700e5..044caf43a588 100644
--- a/extensions/amp-animation/amp-animation.md
+++ b/extensions/amp-animation/amp-animation.md
@@ -657,7 +657,7 @@ For instance:
- `pause` - Pauses the currently running animation.
- `resume` - Resumes the currently running animation.
- `togglePause` - Toggles pause/resume actions.
-- `seekTo` - Pauses the animation and seeks to the point of time specified by the `time` argument in milliseconds.
+- `seekTo` - Pauses the animation and seeks to the point of time specified by the `time` argument in milliseconds or `percent` argument as a percentage point in the timeline.
- `reverse` - Reverses the animation.
- `finish` - Finishes the animation.
- `cancel` - Cancels the animation.
diff --git a/extensions/amp-position-observer/0.1/amp-position-observer.js b/extensions/amp-position-observer/0.1/amp-position-observer.js
new file mode 100644
index 000000000000..0bd059d344f2
--- /dev/null
+++ b/extensions/amp-position-observer/0.1/amp-position-observer.js
@@ -0,0 +1,397 @@
+/**
+ * Copyright 2017 The AMP HTML Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS-IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {ActionTrust} from '../../../src/action-trust';
+import {getServiceForDoc} from '../../../src/service';
+import {Services} from '../../../src/services';
+import {createCustomEvent} from '../../../src/event-helper';
+import {isExperimentOn} from '../../../src/experiments';
+import {dev, user} from '../../../src/log';
+import {
+ RelativePositions,
+ layoutRectsRelativePos,
+ layoutRectLtwh,
+} from '../../../src/layout-rect';
+import {
+ Layout,
+ getLengthNumeral,
+ getLengthUnits,
+ assertLength,
+ parseLength,
+} from '../../../src/layout';
+import {
+ installPositionObserverServiceForDoc,
+ PositionObserverFidelity,
+ PositionInViewportEntryDef,
+} from '../../../src/service/position-observer-impl';
+
+const TAG = 'amp-position-observer';
+
+export class AmpVisibilityObserver extends AMP.BaseElement {
+
+ /** @param {!AmpElement} element */
+ constructor(element) {
+ super(element);
+
+ /** @private {?../../../src/service/action-impl.ActionService} */
+ this.action_ = null;
+
+ /** @private {!boolean} */
+ this.isVisible_ = false;
+
+ /** @private {?../../../src/service/position-observer-impl.AmpDocPositionObserver} */
+ this.positionObserver_ = null;
+
+ /** @private {!number} */
+ this.topRatio_ = 0;
+
+ /** @private {!number} */
+ this.bottomRatio_ = 0;
+
+ /** @private {!string} */
+ this.topMarginExpr_ = '0';
+
+ /** @private {!string} */
+ this.bottomMarginExpr_ = '0';
+
+ /** @private {!number} */
+ this.resolvedTopMargin_ = 0;
+
+ /** @private {!number} */
+ this.resolvedBottomMargin_ = 0;
+
+ /** @private {?../../../src/layout-rect.LayoutRectDef} */
+ this.viewportRect_ = null;
+
+ /** @private {?string} */
+ this.targetId_ = null;
+
+ /** @private {!number} */
+ this.scrollProgress_ = 0;
+ }
+
+ /** @override */
+ isLayoutSupported(layout) {
+ return layout == Layout.NODISPLAY;
+ }
+
+ /** @override */
+ buildCallback() {
+ user().assert(isExperimentOn(this.win, TAG), `${TAG} experiment is off.`);
+ // Since this is a functional component and not visual,
+ // layoutCallback is meaningless. We delay the heavy work until
+ // we become visible.
+ const viewer = Services.viewerForDoc(this.getAmpDoc());
+ viewer.whenFirstVisible().then(this.init_.bind(this));
+ }
+
+ /**
+ * @private
+ */
+ init_() {
+ this.parseAttributes_();
+ this.action_ = Services.actionServiceForDoc(this.element);
+ this.maybeInstallPositionObserver_();
+ this.getAmpDoc().whenReady().then(() => {
+ const scene = this.discoverScene_();
+ this.positionObserver_.observe(scene, PositionObserverFidelity.HIGH,
+ this.positionChanged_.bind(this)
+ );
+ });
+ }
+
+ /**
+ * Dispatches the `enter` event.
+ * @private
+ */
+ triggerEnter_() {
+ const name = 'enter';
+ const event = createCustomEvent(this.win, `${TAG}.${name}`, {});
+ this.action_.trigger(this.element, name, event, ActionTrust.LOW);
+ }
+
+ /**
+ * Dispatches the `exit` event.
+ * @private
+ */
+ triggerExit_() {
+ const name = 'exit';
+ const event = createCustomEvent(this.win, `${TAG}.${name}`, {});
+ this.action_.trigger(this.element, name, event, ActionTrust.LOW);
+ }
+
+ /**
+ * Dispatches the `scroll` event (at most every animation frame)
+ *
+ * This event is triggered only when position-observer triggers which is
+ * at most every animation frame.
+ *
+ * @private
+ */
+ triggerScroll_() {
+ const name = 'scroll';
+ const event = createCustomEvent(this.win, `${TAG}.${name}`,
+ {percent: this.scrollProgress_});
+ this.action_.trigger(this.element, name, event, ActionTrust.LOW);
+ }
+
+ /**
+ * Called by position observer.
+ * It calculates visibility and progress, and triggers the appropriate events.
+ * @param {!../../../src/service/position-observer-impl.PositionInViewportEntryDef} entry PositionObserver entry
+ * @private
+ */
+ positionChanged_(entry) {
+ const wasVisible = this.isVisible_;
+ const prevViewportHeight = this.viewportRect_ && this.viewportRect_.height;
+
+ this.viewportRect_ = entry.viewportRect;
+
+ if (prevViewportHeight != entry.viewportRect.height) {
+ // Margins support viewport sizing.
+ this.recalculateMargins_();
+ }
+
+ // Adjust viewport based on exclusion margins
+ const adjViewportRect = this.applyMargins_(entry.viewportRect);
+ const positionRect = entry.positionRect;
+
+ // Relative position of the element to the adjusted viewport.
+ let relPos;
+ if (!positionRect) {
+ this.isVisible_ = false;
+ relPos = entry.relativePos;
+ } else {
+ relPos = layoutRectsRelativePos(positionRect, adjViewportRect);
+ this.updateVisibility_(positionRect, adjViewportRect, relPos);
+ }
+
+ if (wasVisible && !this.isVisible_) {
+ // Send final scroll progress state before exiting to handle fast-scroll.
+ this.scrollProgress_ = relPos == RelativePositions.BOTTOM ? 0 : 1;
+ this.triggerScroll_();
+ this.triggerExit_();
+ }
+
+ if (!wasVisible && this.isVisible_) {
+ this.triggerEnter_();
+ }
+
+ // Send scroll progress if visible.
+ if (this.isVisible_) {
+ this.updateScrollProgress_(positionRect, adjViewportRect);
+ this.triggerScroll_();
+ }
+ }
+
+ /**
+ * Calculates whether the scene is visible considering ratios and margins.
+ * @param {!../../../src/layout-rect.LayoutRectDef} positionRect position rect as returned by position observer
+ * @param {!../../../src/layout-rect.LayoutRectDef} adjustedViewportRect viewport rect adjusted for margins.
+ * @param {!RelativePositions} relativePos Relative position of rect to viewportRect
+ * @private
+ */
+ updateVisibility_(positionRect, adjustedViewportRect, relativePos) {
+ // Fully inside margin-adjusted viewport.
+ if (relativePos == RelativePositions.INSIDE) {
+ this.isVisible_ = true;
+ return;
+ }
+
+ const ratioToUse = relativePos == RelativePositions.TOP ?
+ this.topRatio_ : this.bottomRatio_;
+
+ const offset = positionRect.height * ratioToUse;
+ if (relativePos == RelativePositions.BOTTOM) {
+ this.isVisible_ =
+ positionRect.top <= (adjustedViewportRect.bottom - offset);
+ } else {
+ this.isVisible_ =
+ positionRect.bottom >= (adjustedViewportRect.top + offset);
+ }
+ }
+
+ /**
+ * Calculates the current scroll progress as a percentage.
+ * Scroll progress is a decimal between 0-1 and shows progress between
+ * enter and exit, considering ratio and margins.
+ * When a scene becomes visible (enters based on ratio and margins), from
+ * bottom, progress is 0 as it moves toward the top, progress increases until
+ * it becomes exists with 1 from the top.
+ *
+ * Entering from the top gives the reverse values, 1 at enter, 0 at exit.
+ * @param {?../../../src/layout-rect.LayoutRectDef} positionRect position rect as returned by position observer
+ * @param {!../../../src/layout-rect.LayoutRectDef} adjustedViewportRect viewport rect adjusted for margins.
+ * @private
+ */
+ updateScrollProgress_(positionRect, adjustedViewportRect) {
+ if (!positionRect) {
+ return;
+ }
+ const totalProgressOffset = (positionRect.height * this.bottomRatio_) +
+ (positionRect.height * this.topRatio_);
+
+ const totalProgress = adjustedViewportRect.height +
+ positionRect.height - totalProgressOffset;
+
+ const topOffset = Math.abs(
+ positionRect.top - this.resolvedTopMargin_ -
+ (adjustedViewportRect.height -
+ (positionRect.height * this.bottomRatio_)
+ )
+ );
+
+ this.scrollProgress_ = topOffset / totalProgress;
+ }
+
+ /**
+ * @private
+ */
+ parseAttributes_() {
+ // Ratio is either "" or ""
+ // e.g, "0.5 1": use 50% visibility at top and 100% at the bottom of viewport.
+ const ratios = this.element.getAttribute('intersection-ratios');
+ if (ratios) {
+ const topBottom = ratios.trim().split(' ');
+ this.topRatio_ = this.validateAndResolveRatio_(topBottom[0]);
+ this.bottomRatio_ = this.topRatio_;
+ if (topBottom[1]) {
+ this.bottomRatio_ = this.validateAndResolveRatio_(topBottom[1]);
+ }
+ }
+
+ // Margin is either "" or ""
+ // e.g, "100px 10vh": exclude 100px from top and 10vh from bottom of viewport.
+ const margins = this.element.getAttribute('viewport-margins');
+ if (margins) {
+ const topBottom = margins.trim().split(' ');
+ this.topMarginExpr_ = topBottom[0];
+ this.bottomMarginExpr_ = this.topMarginExpr_;
+ if (topBottom[1]) {
+ this.bottomMarginExpr_ = topBottom[1];
+ }
+ }
+
+ this.targetId_ = this.element.getAttribute('target');
+ }
+
+ /**
+ * Finds the container scene. Either parent of the component or specified by
+ * `target-id` attribute.
+ * @return {!Element} scene element.
+ * @private
+ */
+ discoverScene_() {
+ let scene;
+ if (this.targetId_) {
+ scene = user().assertElement(
+ this.getAmpDoc().getElementById(this.targetId_),
+ 'No element found with id:' + this.targetId_);
+ } else {
+ scene = this.element.parentNode;
+ }
+ // Hoist body to documentElement.
+ if (this.getAmpDoc().getBody() == scene) {
+ scene = this.win.document.documentElement;
+ }
+
+ return dev().assertElement(scene);
+ }
+
+ /**
+ * Parses and validates margins.
+ * @private
+ * @param {string} val
+ * @return {!number} resolved margin
+ */
+ validateAndResolveMargin_(val) {
+ val = assertLength(parseLength(val));
+ const unit = getLengthUnits(val);
+ let num = getLengthNumeral(val);
+ if (!num) {
+ return 0;
+ }
+ user().assert(unit == 'px' || unit == 'vh', 'Only pixel or vh are valid ' +
+ 'as units for exclusion margins: ' + val);
+
+ if (unit == 'vh') {
+ num = (num / 100) * this.viewportRect_.height;
+ }
+ return num;
+ }
+
+ /**
+ * Parses and validates ratios.
+ * @param {string} val
+ * @return {!number} resolved ratio
+ * @private
+ */
+ validateAndResolveRatio_(val) {
+ const num = parseFloat(val);
+ user().assert(num >= 0 && num <= 1,
+ 'Ratios must be a decimal between 0 and 1: ' + val);
+ return num;
+ }
+
+ /**
+ * Margins can be of `vh` unit which means they may need to be recalculated
+ * when viewport height changes.
+ * @private
+ */
+ recalculateMargins_() {
+ dev().assert(this.viewportRect_);
+ dev().assert(this.bottomMarginExpr_);
+ dev().assert(this.topMarginExpr_);
+
+ this.resolvedTopMargin_ =
+ this.validateAndResolveMargin_(this.topMarginExpr_);
+
+ this.resolvedBottomMargin_ =
+ this.validateAndResolveMargin_(this.bottomMarginExpr_);
+ }
+
+ /**
+ * Readjusts the given rect using the configured exclusion margins.
+ * @param {!../../../src/layout-rect.LayoutRectDef} rect viewport rect adjusted for margins.
+ * @private
+ */
+ applyMargins_(rect) {
+ dev().assert(rect);
+ rect = layoutRectLtwh(
+ rect.left,
+ (rect.top + this.resolvedTopMargin_),
+ rect.width,
+ (rect.height - this.resolvedBottomMargin_ - this.resolvedTopMargin_)
+ );
+
+ return rect;
+ }
+
+ /**
+ * @private
+ */
+ maybeInstallPositionObserver_() {
+ if (!this.positionObserver_) {
+ installPositionObserverServiceForDoc(this.getAmpDoc());
+ this.positionObserver_ = getServiceForDoc(
+ this.getAmpDoc(),
+ 'position-observer'
+ );
+ }
+ }
+}
+
+AMP.registerElement(TAG, AmpVisibilityObserver);
diff --git a/extensions/amp-position-observer/0.1/test/test-amp-position-observer.js b/extensions/amp-position-observer/0.1/test/test-amp-position-observer.js
new file mode 100644
index 000000000000..63203320280b
--- /dev/null
+++ b/extensions/amp-position-observer/0.1/test/test-amp-position-observer.js
@@ -0,0 +1,1061 @@
+/**
+ * Copyright 2017 The AMP HTML Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS-IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {AmpVisibilityObserver} from '../amp-position-observer';
+import {layoutRectLtwh, RelativePositions} from '../../../../src/layout-rect';
+
+/**
+ * Functional tests that create:
+ * - 1000px viewport
+ * - 200px container
+ * - moves the container in the viewport and tests enter, exit, progress values,
+ * with various ratio and margin configurations
+ */
+describes.sandboxed('amp-position-observer', {}, () => {
+ let impl;
+ let enterSpy;
+ let exitSpy;
+ let scrollSpy;
+
+ const BELOW_VP = 2000;
+ const ABOVE_VP = -1000;
+ const INSIDE_VP = 500;
+
+ function init(ratios = '0', margins = '0') {
+ const elem = {
+ getAttribute(attr) {
+ if (attr == 'intersection-ratios') {
+ return ratios;
+ }
+ if (attr == 'viewport-margins') {
+ return margins;
+ }
+ },
+ };
+ elem.ownerDocument = {
+ defaultView: window,
+ };
+
+ impl = new AmpVisibilityObserver(elem);
+ impl.parseAttributes_();
+ enterSpy = sandbox.stub(impl, 'triggerEnter_');
+ exitSpy = sandbox.stub(impl, 'triggerExit_');
+ scrollSpy = sandbox.stub(impl, 'triggerScroll_');
+ }
+
+ function resetSpies() {
+ enterSpy.reset();
+ exitSpy.reset();
+ scrollSpy.reset();
+ }
+
+ function setPosition(top) {
+ const viewportRect = layoutRectLtwh(0, 0, 500, 1000);
+ let positionRect = layoutRectLtwh(0, top, 500, 200);
+ let relativePos = RelativePositions.INSIDE;
+
+ if (top > 1000 + 200) {
+ positionRect = null;
+ relativePos = RelativePositions.BOTTOM;
+ } else if (top < -200) {
+ positionRect = null;
+ relativePos = RelativePositions.TOP;
+ }
+ const entry = {
+ viewportRect,
+ positionRect,
+ relativePos,
+ };
+ impl.positionChanged_(entry);
+ }
+
+ describe('no ratio, no margin', () => {
+ /**
+ * With no ratio, no margin, element progresses as soon as partially visible
+ * until it is fully invisible.
+ *
+ * *******
+ * * end *
+ * |--*******--|
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * |--*******--|
+ * *start*
+ * *******
+ */
+
+ describe('not initially in viewport', () => {
+ it('should not trigger enter', () => {
+ init();
+ expect(enterSpy).not.to.be.called;
+
+ setPosition(BELOW_VP);
+ expect(enterSpy).not.to.be.called;
+
+ setPosition(ABOVE_VP);
+ expect(enterSpy).not.to.be.called;
+ });
+
+ it('should not trigger exit', () => {
+ init();
+ expect(exitSpy).not.to.be.called;
+
+ setPosition(BELOW_VP);
+ expect(exitSpy).not.to.be.called;
+
+ setPosition(ABOVE_VP);
+ expect(exitSpy).not.to.be.called;
+ });
+
+ it('should not trigger scroll', () => {
+ init();
+ expect(scrollSpy).not.to.be.called;
+
+ setPosition(BELOW_VP);
+ expect(scrollSpy).not.to.be.called;
+
+ setPosition(ABOVE_VP);
+ expect(scrollSpy).not.to.be.called;
+ });
+ });
+
+ describe('initially in viewport', () => {
+ it('should trigger enter', () => {
+ init();
+ expect(enterSpy).not.to.be.called;
+ setPosition(INSIDE_VP);
+ expect(enterSpy).to.be.calledOnce;
+ });
+
+ it('should not trigger exit', () => {
+ init();
+ expect(exitSpy).not.to.be.called;
+ setPosition(INSIDE_VP);
+ expect(exitSpy).not.to.be.called;
+ });
+
+ it('should trigger scroll', () => {
+ init();
+ expect(scrollSpy).not.to.be.called;
+ setPosition(INSIDE_VP);
+ expect(scrollSpy).to.be.calledOnce;
+ });
+ });
+
+ describe('enters viewport', () => {
+ it('should trigger enter/scroll - enter from above', () => {
+ init();
+
+ setPosition(ABOVE_VP);
+ expect(enterSpy).not.to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(scrollSpy).not.to.be.called;
+
+ setPosition(INSIDE_VP);
+ expect(enterSpy).to.be.calledOnce;
+ expect(scrollSpy).to.be.calledOnce;
+ expect(exitSpy).not.to.be.called;
+ });
+
+ it('should trigger enter/scroll - enter from below', () => {
+ init();
+
+ setPosition(BELOW_VP);
+ expect(enterSpy).not.to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(scrollSpy).not.to.be.called;
+
+ setPosition(INSIDE_VP);
+ expect(enterSpy).to.be.calledOnce;
+ expect(scrollSpy).to.be.calledOnce;
+ expect(exitSpy).not.to.be.called;
+ });
+ });
+
+ describe('exits viewport', () => {
+ it('should trigger exit/scroll - exits to above', () => {
+ init();
+
+ setPosition(INSIDE_VP);
+ expect(enterSpy).to.be.calledOnce;
+ expect(scrollSpy).to.be.calledOnce;
+ expect(exitSpy).not.to.be.called;
+
+ resetSpies();
+ setPosition(ABOVE_VP);
+
+ expect(scrollSpy).to.be.calledOnce;
+ expect(exitSpy).to.be.calledOnce;
+ });
+
+ it('should trigger exit/scroll - exits to below', () => {
+ init();
+
+ setPosition(INSIDE_VP);
+ expect(enterSpy).to.be.calledOnce;
+ expect(scrollSpy).to.be.calledOnce;
+ expect(exitSpy).not.to.be.called;
+
+ resetSpies();
+ setPosition(BELOW_VP);
+
+ expect(scrollSpy).to.be.calledOnce;
+ expect(exitSpy).to.be.calledOnce;
+ });
+ });
+
+ /*
+ * Without any ratio/margins items becomes visible and starts reporting
+ * progress as soon:
+ * FROM BOTTOM: Its top hits the bottom edge of VP
+ * FROM TOP: Its bottom hits the top edge of VP.
+ * *******
+ * * end *
+ * |--*******--|
+ * | |
+ * | |
+ * | |
+ | |
+ * | |
+ * | |
+ * | |
+ * --*******--
+ * *start*
+ * *******
+ */
+ describe('scroll progress', () => {
+ it('should report scroll progress - from bottom', () => {
+ init();
+
+ // one pixel below
+ setPosition(1001);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 0%
+ setPosition(1000);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(0);
+
+ // one more pixel
+ setPosition(999);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // middle - when middle of element is in the middle of viewport
+ // vpHeight(100)/2 - elemHeight(200)/2 = 400
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(-199);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(-200);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 1
+ setPosition(-201);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).to.be.called;
+
+ resetSpies();
+ // rerenter
+
+ // one pixel above
+ setPosition(-201);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 100% (coming from top)
+ setPosition(-200);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(1);
+
+ // one more pixel
+ setPosition(-199);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // middle - when middle of element is in the middle of viewport
+ // vpHeight(100)/2 - elemHeight(200)/2 = 400
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(999);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge
+ setPosition(1000);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 0
+ setPosition(1001);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).to.be.called;
+ });
+ });
+ });
+
+ describe('has ratio, no margin', () => {
+ /**
+ * with both ratios as 1, element only progresses when fully visible
+ * --*******--
+ * | * end * |
+ * | ******* |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | ******* |
+ * | *start* |
+ * --*******--
+ */
+ it('top: 1, bottom: 1', () => {
+ init('1');
+
+ // start just below
+ setPosition(801);
+ expect(scrollSpy).not.to.be.called;
+ expect(enterSpy).not.to.be.called;
+ expect(exitSpy).not.to.be.called;
+
+ // hit visibility edge ( element fully visible with means
+ // vpHeight(1000) - elementHeight(200) = 800
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // scroll up more
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // 1/4
+ setPosition(600);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.25);
+
+ // 3/4
+ setPosition(200);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.75);
+
+ // about to exit
+ setPosition(1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // exit
+ setPosition(-1);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ resetSpies();
+
+ // re-enter
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.calledOnce;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // hit middle
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.calledOnce;
+ expect(impl.scrollProgress_).to.be.equals(0.5);
+
+ // about to exit from bottom
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge at bottom
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // exit from bottom
+ setPosition(801);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ });
+
+ /**
+ * with both ratios as 0.5, element progresses when half visible
+ * *******
+ * |--* end *--|
+ * | ******* |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | ******* |
+ * |--*start*--|
+ * *******
+ */
+ it('top: 0.5, bottom: 0.5', () => {
+ init('0.5');
+
+ // start just below
+ setPosition(901);
+ expect(scrollSpy).not.to.be.called;
+ expect(enterSpy).not.to.be.called;
+ expect(exitSpy).not.to.be.called;
+
+ // hit visibility edge ( element fully visible with means
+ // vpHeight(1000) - elementHeight(200) / 2 = 900
+ setPosition(900);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // scroll up more
+ setPosition(899);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // 1/4
+ setPosition(650);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.25);
+
+ // 3/4
+ setPosition(150);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.75);
+
+ // about to exit
+ setPosition(-99);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(-100);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // exit
+ setPosition(-101);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ resetSpies();
+
+ // re-enter
+ setPosition(-100);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.calledOnce;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // hit middle
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.calledOnce;
+ expect(impl.scrollProgress_).to.be.equals(0.5);
+
+ // about to exit from bottom
+ setPosition(899);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge at bottom
+ setPosition(900);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // exit from bottom
+ setPosition(901);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ });
+
+ /**
+ * top: 0 bottom: 1
+ * *******
+ * * end *
+ * |--*******--|
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | ******* |
+ * | *start* |
+ * |--*******--
+ */
+ it('top: 0, bottom: 1', () => {
+ init('0 1');
+
+ // start just below
+ setPosition(801);
+ expect(scrollSpy).not.to.be.called;
+ expect(enterSpy).not.to.be.called;
+ expect(exitSpy).not.to.be.called;
+
+ // hit visibility
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // scroll up more
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // about to exit
+ setPosition(-199);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(-200);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // exit
+ setPosition(-201);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ resetSpies();
+
+ // re-enter
+ setPosition(-200);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.calledOnce;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // about to exit from bottom
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge at bottom
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // exit from bottom
+ setPosition(801);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ });
+
+ /**
+ * top: 1 bottom: 0
+ * |--*******--|
+ * | * end * |
+ * | ******* |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * | |
+ * |--*******--|
+ * *start*
+ * *******
+ */
+ it('top: 1, bottom: 0', () => {
+ init('1 0');
+
+ // start just below
+ setPosition(1001);
+ expect(scrollSpy).not.to.be.called;
+ expect(enterSpy).not.to.be.called;
+ expect(exitSpy).not.to.be.called;
+
+ // hit visibility
+ setPosition(1000);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // scroll up more
+ setPosition(999);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // about to exit
+ setPosition(1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // exit
+ setPosition(-1);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ resetSpies();
+
+ // re-enter
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.calledOnce;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // about to exit from bottom
+ setPosition(999);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge at bottom
+ setPosition(1000);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // exit from bottom
+ setPosition(1001);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ });
+
+ /**
+ * top: 0.75 bottom: 0.2
+ * custom, can't ASCII draw thing one.
+ */
+ it('top: 0.75, bottom: 0.2', () => {
+ init('0.75 0.2');
+
+ // 1000 - 40 (0.2*200)
+ setPosition(961);
+ expect(scrollSpy).not.to.be.called;
+ expect(enterSpy).not.to.be.called;
+ expect(exitSpy).not.to.be.called;
+
+ // hit visibility
+ setPosition(960);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // scroll up more
+ setPosition(959);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // 0.75 * 200 = 150. 200 -150 = 50
+ setPosition(-49);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(-50);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).not.to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // exit
+ setPosition(-51);
+ expect(scrollSpy).to.be.called;
+ expect(exitSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ resetSpies();
+
+ // re-enter
+ setPosition(-50);
+ expect(scrollSpy).to.be.called;
+ expect(enterSpy).to.be.calledOnce;
+ expect(impl.scrollProgress_).to.be.equals(1);
+
+ // about to exit from bottom
+ setPosition(959);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge at bottom
+ setPosition(960);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+
+ // exit from bottom
+ setPosition(961);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ });
+ });
+
+ describe('has margin, no ratio', () => {
+ /**
+ * margins essentially just narrow the viewport.
+ * here we have 100px margin on top and 100px margin on bottom
+ */
+ it('topMargin: 100px, bottomMargin: 100px', () => {
+ init('0', '100');
+
+ // one pixel below
+ setPosition(901);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 0%
+ setPosition(900);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(0);
+
+ // one more pixel
+ setPosition(899);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(-99);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(-100);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 1
+ setPosition(-101);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).to.be.called;
+
+ resetSpies();
+ // rerenter
+
+ // one pixel above
+ setPosition(-101);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 100% (coming from top)
+ setPosition(-100);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(1);
+
+ // one more pixel
+ setPosition(-99);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // middle
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(899);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge
+ setPosition(900);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 0
+ setPosition(901);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).to.be.called;
+ });
+
+ it('topMargin: 20vh, bottomMargin: 20vh', () => {
+ // 10vh = 20% of vpHeight = 200px
+ init('0', '20vh');
+
+ // one pixel below
+ setPosition(801);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 0%
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(0);
+
+ // one more pixel
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 1
+ setPosition(-1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).to.be.called;
+
+ resetSpies();
+ // rerenter
+
+ // one pixel above
+ setPosition(-1);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 100% (coming from top)
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(1);
+
+ // one more pixel
+ setPosition(1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // middle
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 0
+ setPosition(801);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).to.be.called;
+ });
+
+ it('topMargin: 0px, bottomMargin: 100px', () => {
+ init('0', '0 100');
+
+ // one pixel below
+ setPosition(901);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 0%
+ setPosition(900);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(0);
+
+ // one more pixel
+ setPosition(899);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ setPosition(350);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(-199);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(-200);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 1
+ setPosition(-201);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).to.be.called;
+
+ resetSpies();
+ // rerenter
+
+ // one pixel above
+ setPosition(-201);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 100% (coming from top)
+ setPosition(-200);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(1);
+
+ // one more pixel
+ setPosition(-199);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // middle
+ setPosition(350);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(899);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge
+ setPosition(900);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 0
+ setPosition(901);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).to.be.called;
+ });
+ });
+
+ describe('with both margin and ratio', () => {
+ it('ratio: 0.5 padding: 100px', () => {
+ init('0.5', '100');
+
+ // one pixel below
+ setPosition(801);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 0%
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(0);
+
+ // one more pixel
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // exit edge
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 1
+ setPosition(-1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(1);
+ expect(exitSpy).to.be.called;
+
+ resetSpies();
+ // rerenter
+
+ // one pixel above
+ setPosition(-1);
+ expect(scrollSpy).not.to.be.called;
+
+ // right on edge, progress is 100% (coming from top)
+ setPosition(0);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.equal(1);
+
+ // one more pixel
+ setPosition(1);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.below(1);
+
+ // middle
+ setPosition(400);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0.5);
+
+ // about to exit
+ setPosition(799);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.above(0);
+
+ // exit edge
+ setPosition(800);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).not.to.be.called;
+
+ // exited, progress should stay 0
+ setPosition(801);
+ expect(scrollSpy).to.be.called;
+ expect(impl.scrollProgress_).to.be.equal(0);
+ expect(exitSpy).to.be.called;
+ });
+ });
+});
+
+
+
diff --git a/extensions/amp-position-observer/0.1/test/validator-amp-position-observer.html b/extensions/amp-position-observer/0.1/test/validator-amp-position-observer.html
new file mode 100644
index 000000000000..679155104a74
--- /dev/null
+++ b/extensions/amp-position-observer/0.1/test/validator-amp-position-observer.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/amp-position-observer/0.1/test/validator-amp-position-observer.out b/extensions/amp-position-observer/0.1/test/validator-amp-position-observer.out
new file mode 100644
index 000000000000..2a768a11412a
--- /dev/null
+++ b/extensions/amp-position-observer/0.1/test/validator-amp-position-observer.out
@@ -0,0 +1,22 @@
+FAIL
+amp-position-observer/0.1/test/validator-amp-position-observer.html:49:2 The implied layout 'CONTAINER' is not supported by tag 'amp-position-observer'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_LAYOUT_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:51:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value ''. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:52:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '1.1'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:53:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '0.b'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:54:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value 'foo'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:55:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '3'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:56:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '0.a 0.b'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:57:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '0.5 foo'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:58:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '0.5 1.1'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:59:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '0.5 4'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:60:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value 'foo 0.5'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:61:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '1.1 0.5'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:62:2 The attribute 'intersection-ratios' in tag 'amp-position-observer' is set to the invalid value '4 0.5'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:64:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value ''. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:65:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value 'foo'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:66:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value '100fx'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:67:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value '100vx'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:68:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value '100 100vx'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:69:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value '100vh 100vx'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:70:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value '100fx 100vh'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
+amp-position-observer/0.1/test/validator-amp-position-observer.html:71:2 The attribute 'viewport-margins' in tag 'amp-position-observer' is set to the invalid value '100vx 100px'. (see https://www.ampproject.org/docs/reference/components/amp-position-observer) [AMP_TAG_PROBLEM]
diff --git a/extensions/amp-position-observer/OWNERS.yaml b/extensions/amp-position-observer/OWNERS.yaml
new file mode 100644
index 000000000000..eb1254c6948a
--- /dev/null
+++ b/extensions/amp-position-observer/OWNERS.yaml
@@ -0,0 +1 @@
+- aghassemi
diff --git a/extensions/amp-position-observer/amp-position-observer.md b/extensions/amp-position-observer/amp-position-observer.md
new file mode 100644
index 000000000000..87f9f6746e27
--- /dev/null
+++ b/extensions/amp-position-observer/amp-position-observer.md
@@ -0,0 +1,230 @@
+
+
+# `amp-position-observer`
+
+
+
+
Description
+
Monitors position of an element within the viewport as user scrolls
+ and dispatches `enter`, `exit` and `scroll` events that can be used with
+ other components such as ``
+
+
+[TOC]
+
+## What is amp-position-observer?
+`amp-position-observer` is a functional component that monitors position of an
+element within the viewport as user scrolls and dispatches
+`enter`, `exit` and `scroll:` events (**Low Trust Level**)
+which can be used to trigger actions (**Only Low Trust Actions**) on other components.
+It is only useful when used with other components and does not do anything on its own.
+
+## What can I do with amp-position-observer?
+Currently [amp-animation](https://www.ampproject.org/docs/reference/components/amp-animation)
+and several video players in AMP are the only components that allow low-trust events
+to trigger their actions such as starting an animation, seeking to a position
+within the animation, pausing a video, etc...
+
+### Scroll-bound animations
+`amp-animation` exposes a `seekTo` action that can be tied to the `scroll` event
+of `amp-position-observer` to implement scroll-bound animations.
+
+### Example:
+Imagine an animation where the hour hand of a clock rotates as user scrolls
+the page.
+
+
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Animation scenes that start/pause based on visibility in the viewport
+`amp-animation` also exposes `start` and `pause` actions that can be tied to the
+`enter` and `exit` events of `amp-position-observer` to control when animation
+starts/pauses based on visibility.
+
+`amp-position-observer` exposes various visibility configurations such as
+`intersection-ratios` and `viewport-margins` (similar to [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)) that
+can be used to fine-tune when the target is considered visible.
+
+## Example
+Consider the same clock animation, but this time the hand animates with time, except
+we like the animation to start when clock is at least 50% visible and pause as soon
+as clock becomes less than 50% visible.
+
+
+
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Attributes
+
+#### target-id (optional)
+Specifies what element to observe via its ID.
+If **not specified** the **parent** of `` will be used as the target.
+
+#### intersection-ratios (optional)
+
+A number between 0 and 1 which defines how much of the target should be visible in
+the viewport before `` triggers any of its events.
+
+Different ratios for top vs. bottom can be specified by providing two values (``).
+
+Defaults to 0.
+
+`intersection-ratios="0"` means `enter` is triggered as soon as a single pixel
+of the target comes into viewport and `exit` is triggered as soon as the very last pixel
+of the target goes out of the viewport.
+
+`intersection-ratios="0.5"` means `enter` is triggered as soon as 50% of
+of the target comes into viewport and `exit` is triggered as soon as less than
+50% of the target is in the viewport.
+
+
+`intersection-ratios="1"` means `enter` is triggered when target is fully visible
+and `exit` is triggered as soon as a single pixel goes out of the viewport.
+
+
+`intersection-ratios="0 1"` makes the conditions different depending on whether
+the target is entering/exiting from top (0 will be used) or bottom (1 will be used).
+
+
+#### viewport-margins (optional)
+
+A `px` or `vh` value which can be used to shrink the area of the viewport used
+for visibility calculations. A number without a unit will be assumed `px`
+
+Different values for top vs. bottom can be specified by providing two values (``).
+
+Defaults to 0.
+
+`viewport-margins="100px"` means shrink the viewport by 100px from the top and 100px from the bottom.
+
+`viewport-margins="25vh"` means shrink the viewport by 25% from the top and 25% from the bottom.
+Effectively only considering the middle 50% of the viewport.
+
+`viewport-margins="100px 10vh"` means shrink the viewport by 100px from the top and 10% from the bottom.
+
+## Validation
+
+See [amp-position-observer rules](https://github.com/ampproject/amphtml/blob/master/extensions/amp-position-observer/validator-amp-position-observer.protoascii) in the AMP validator specification.
diff --git a/extensions/amp-position-observer/validator-amp-position-observer.protoascii b/extensions/amp-position-observer/validator-amp-position-observer.protoascii
new file mode 100644
index 000000000000..e98ba4d73832
--- /dev/null
+++ b/extensions/amp-position-observer/validator-amp-position-observer.protoascii
@@ -0,0 +1,48 @@
+#
+# Copyright 2017 The AMP HTML Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the license.
+#
+
+tags: { # amp-position-observer
+ html_format: AMP
+ tag_name: "SCRIPT"
+ extension_spec: {
+ name: "amp-position-observer"
+ allowed_versions: "0.1"
+ allowed_versions: "latest"
+ }
+ attr_lists: "common-extension-attrs"
+}
+tags: { #
+ html_format: AMP
+ tag_name: "AMP-POSITION-OBSERVER"
+ requires_extension: "amp-position-observer"
+ attrs: {
+ name: "intersection-ratios"
+ # Regex ^([0]*?\.\d*$|1$|0$)|([0]*?\.\d*|1|0)\s{1}([0]*?\.\d*$|1$|0$)
+ # Values such as: "0", "0.1" , "1", "0 1", "0 0.5", "0.5 1", "1 1", etc...
+ value_regex: "^([0]*?\\.\\d*$|1$|0$)|([0]*?\\.\\d*|1|0)\s{1}([0]*?\\.\\d*$|1$|0$)"
+ }
+ attrs: { name: "target" }
+ attrs: {
+ name: "viewport-margins"
+ # Regex: ^(\d+$|\d+px$|\d+vh$)|((\d+|\d+px|\d+vh)\s{1}(\d+$|\d+px$|\d+vh$))
+ # Values such as: "100", "100px", "100vh", "100 100px", "100vh 100", "100px 100vh", etc..
+ value_regex: "^(\\d+$|\\d+px$|\\d+vh$)|((\\d+|\\d+px|\\d+vh)\\s{1}(\\d+$|\\d+px$|\\d+vh$))"
+ }
+ attr_lists: "extended-amp-global"
+ amp_layout {
+ supported_layouts: NODISPLAY
+ }
+}
diff --git a/gulpfile.js b/gulpfile.js
index 0fa3d8650c37..e193c5e7a3e3 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -115,6 +115,7 @@ declareExtension('amp-springboard-player', '0.1', false);
declareExtension('amp-sticky-ad', '1.0', true);
declareExtension('amp-selector', '0.1', true);
declareExtension('amp-web-push', '0.1', true);
+declareExtension('amp-position-observer', '0.1', false);
/**
* @deprecated `amp-slides` is deprecated and will be deleted before 1.0.
diff --git a/src/base-element.js b/src/base-element.js
index 5720ff29ab3d..bd9d18a1d433 100644
--- a/src/base-element.js
+++ b/src/base-element.js
@@ -558,7 +558,7 @@ export class BaseElement {
executeAction(invocation, unusedDeferred) {
if (invocation.method == 'activate') {
if (invocation.satisfiesTrust(this.activationTrust())) {
- this.activate(invocation);
+ return this.activate(invocation);
}
} else {
this.initActionMap_();
@@ -567,7 +567,7 @@ export class BaseElement {
this);
const {handler, minTrust} = holder;
if (invocation.satisfiesTrust(minTrust)) {
- handler(invocation);
+ return handler(invocation);
}
}
}
diff --git a/src/service/video-manager-impl.js b/src/service/video-manager-impl.js
index 7e3762176913..1c3e2c6acc8b 100644
--- a/src/service/video-manager-impl.js
+++ b/src/service/video-manager-impl.js
@@ -159,7 +159,7 @@ export class VideoManager {
this.timer_ = Services.timerFor(ampdoc.win);
/** @private @const */
- this.boundSecondsPlaying_ = () => this.secondsPlaying_();;
+ this.boundSecondsPlaying_ = () => this.secondsPlaying_();
// TODO(cvializ, #10599): It would be nice to only create the timer
// if video analytics are present, since the timer is not needed if
diff --git a/src/utils/math.js b/src/utils/math.js
index 2ee2bc212bc9..00c89b43c502 100644
--- a/src/utils/math.js
+++ b/src/utils/math.js
@@ -28,11 +28,11 @@
* Ex1: -2 in the range [0, 10] is interpreted as 0 and thus gives 40 in [40,80]
* Ex2: 19 in the range [0, 5] is interpreted as 5 and thus gives 80 in [40,80]
*
- * @param {number} val the value in the source range
- * @param {number} min1 the lower bound of the source range
- * @param {number} max1 the upper bound of the source range
- * @param {number} min2 the lower bound of the target range
- * @param {number} max2 the upper bound of the target range
+ * @param {!number} val the value in the source range
+ * @param {!number} min1 the lower bound of the source range
+ * @param {!number} max1 the upper bound of the source range
+ * @param {!number} min2 the lower bound of the target range
+ * @param {!number} max2 the upper bound of the target range
* @return {!number} the equivalent value in the target range
*/
export function mapRange(val, min1, max1, min2, max2) {
@@ -52,3 +52,20 @@ export function mapRange(val, min1, max1, min2, max2) {
return (val - min1) * (max2 - min2) / (max1 - min1) + min2;
};
+
+/**
+ * Restricts a number to be in the given min/max range.
+ *
+ * Examples:
+ * clamp(0.5, 0, 1) -> 0.5
+ * clamp(1.5, 0, 1) -> 1
+ * clamp(-0.5, 0, 1) -> 0
+ *
+ * @param {!number} val the value to clamp.
+ * @param {!number} min the lower bound.
+ * @param {!number} max the upper bound.
+ * @return {!number} the clamped value.
+ */
+export function clamp(val, min, max) {
+ return Math.min(Math.max(val, min), max);
+};
diff --git a/test/functional/utils/test-math.js b/test/functional/utils/test-math.js
index df8d0686a9c4..16930a6f79ae 100644
--- a/test/functional/utils/test-math.js
+++ b/test/functional/utils/test-math.js
@@ -14,30 +14,59 @@
* limitations under the License.
*/
-import {
- mapRange,
-} from '../../../src/utils/math';
+import {mapRange, clamp} from '../../../src/utils/math';
-describes.sandboxed('mapRange', {}, () => {
+describes.sandboxed('utils/math', {}, () => {
- it('should map a number to the corrent value', () => {
- expect(mapRange(5, 0, 10, 40, 80)).to.equal(60);
- expect(mapRange(5, 0, 10, 10, 20)).to.equal(15);
- });
+ describe('mapRange', () => {
+ it('should map a number to the current value', () => {
+ expect(mapRange(5, 0, 10, 40, 80)).to.equal(60);
+ expect(mapRange(5, 0, 10, 10, 20)).to.equal(15);
+ });
- it('should automatically detect source range bounds order', () => {
- expect(mapRange(5, 10, 0, 40, 80)).to.equal(60);
- expect(mapRange(8, 10, 0, 10, 20)).to.equal(12);
- });
+ it('should automatically detect source range bounds order', () => {
+ expect(mapRange(5, 10, 0, 40, 80)).to.equal(60);
+ expect(mapRange(8, 10, 0, 10, 20)).to.equal(12);
+ });
- it('should accept decreasing target ranges', () => {
- expect(mapRange(8, 0, 10, 10, 0)).to.equal(2);
- });
+ it('should accept decreasing target ranges', () => {
+ expect(mapRange(8, 0, 10, 10, 0)).to.equal(2);
+ });
- it('should constrain input to the source range', () => {
- expect(mapRange(-2, 0, 10, 10, 20)).to.equal(10);
- expect(mapRange(50, 0, 10, 10, 20)).to.equal(20);
- expect(mapRange(19, 0, 5, 40, 80)).to.equal(80);
+ it('should constrain input to the source range', () => {
+ expect(mapRange(-2, 0, 10, 10, 20)).to.equal(10);
+ expect(mapRange(50, 0, 10, 10, 20)).to.equal(20);
+ expect(mapRange(19, 0, 5, 40, 80)).to.equal(80);
+ });
});
+ describe('clamp', () => {
+ it('should not clamp if within the range', () => {
+ expect(clamp(0.5, 0, 1)).to.equal(0.5);
+ expect(clamp(-10, -20, 0)).to.equal(-10);
+ expect(clamp(1000, -Infinity, Infinity)).to.equal(1000);
+ });
+
+ it('should be inclusive of the range', () => {
+ expect(clamp(1, 0, 1)).to.equal(1);
+ expect(clamp(0, 0, 1)).to.equal(0);
+ expect(clamp(-20, -20, 0)).to.equal(-20);
+ expect(clamp(0, -20, 0)).to.equal(0);
+ });
+
+ it('should clamp larger values', () => {
+ expect(clamp(1.2, 0, 1)).to.equal(1);
+ expect(clamp(4, 0, 1)).to.equal(1);
+ expect(clamp(1.0001, 0, 1)).to.equal(1);
+ expect(clamp(0.1, -20, 0)).to.equal(0);
+ });
+
+ it('should clamp smaller values', () => {
+ expect(clamp(-0.2, 0, 1)).to.equal(0);
+ expect(clamp(-5, 0, 1)).to.equal(0);
+ expect(clamp(-0.0001, 0, 1)).to.equal(0);
+ expect(clamp(-21, -20, 0)).to.equal(-20);
+ });
+ });
});
+
diff --git a/test/integration/test-position-observer.js b/test/integration/test-position-observer.js
new file mode 100644
index 000000000000..b59541fc4365
--- /dev/null
+++ b/test/integration/test-position-observer.js
@@ -0,0 +1,210 @@
+/**
+ * Copyright 2016 The AMP HTML Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS-IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {poll} from '../../testing/iframe';
+
+//TODO(aghassemi,#10878): Run in all platforms.
+//TODO(aghasemi, #10877): in-a-box, FIE integration tests.
+const config = describe.configure().ifChrome().skipOldChrome();
+config.run('amp-position-observer', function() {
+ this.timeout(100000);
+
+ const css = `
+ .spacer {
+ height: 100vh;
+ width: 2px;
+ }
+
+ #animTarget {
+ opacity: 0;
+ height: 10px;
+ width: 100%;
+ background-color: red;
+ }
+ `;
+
+ const extensions = ['amp-animation', 'amp-position-observer'];
+ const experiments = ['amp-animation', 'amp-position-observer'];
+
+ const scrollboundBody = `
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ /*
+ * scrollbound amp-animation will make the target will go
+ * from opacity 0 to 1 with scroll.
+ **/
+ describes.integration('scrollbound animation', {
+ body: scrollboundBody,
+ css,
+ extensions,
+ experiments,
+ }, env => {
+ it('runs animation with scroll', () => {
+ // Not visible yet, opacity = 0;
+ expect(getOpacity(env.win)).to.equal(0);
+
+ // Scroll bring to middle of viewport, height of target is 10
+ env.win.scrollTo(0, getViewportHeight(env.win) / 2 + 5);
+ // Half way: opacity = 0.5
+ return waitForOpacity(env.win, 'equals', 0.5).then(() => {
+ // Scroll to the end
+ env.win.scrollTo(0, getViewportHeight(env.win) * 2);
+ // All the way: opacity = 1;
+ return waitForOpacity(env.win, 'equals', 1);
+ }).then(() => {
+ // Scroll back to the top
+ env.win.scrollTo(0, 0);
+ // Back to starting position: opacity: 0
+ return waitForOpacity(env.win, 'equals', 0);
+ });
+ });
+ });
+
+
+ const animationSceneBody = `
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ /*
+ * Animation scene will start when 50% visible above the 10vh margin
+ * and paused when 50% invisible below the 10vh margin.
+ * There is no scrollbound behavior, purely time-based animation.
+ **/
+ describes.integration('animation scene', {
+ body: animationSceneBody,
+ css,
+ extensions,
+ experiments,
+ }, env => {
+
+ it('plays/pauses animation scene based on visibility', () => {
+ // Not visible yet, opacity = 0;
+ expect(getOpacity(env.win)).to.equal(0);
+ // Scroll to edge of visibility
+ // ratio is 0.5 and height of element is 10
+ // exclusion margin is 10% of viewport
+ // so we need to scroll = 10% * vh + 5px;
+ const scrollBy = getViewportHeight(env.win) * 0.1 + 5;
+ env.win.scrollTo(0, scrollBy);
+ return waitForOpacity(env.win, 'greater-than', 0).then(() => {
+ // Scroll to the end
+ env.win.scrollTo(0, getViewportHeight(env.win) * 2);
+
+ // Now we need to ensure opacity is not changing anymore to prove
+ // animation is paused.
+ return ensureOpacityIsNoChangingAnymore(env.win);
+ }).then(() => {
+ // Ok, animation is paused and given the long duration, opacity must be
+ // stuck somewhere between 0 and 1
+ const opacity = getOpacity(env.win);
+ expect(opacity).to.be.above(0);
+ expect(opacity).to.be.below(1);
+ });
+
+ });
+ });
+});
+
+function getOpacity(win) {
+ const animTarget = win.document.querySelector('#animTarget');
+ return parseFloat(win.getComputedStyle(animTarget).opacity);
+};
+
+function waitForOpacity(win, comparison, factor) {
+ return poll('wait for opacity to ' + comparison + ': ' + factor, () => {
+ if (comparison == 'equals') {
+ return getOpacity(win) == factor;
+ }
+
+ if (comparison == 'greater-than') {
+ return getOpacity(win) > factor;
+ }
+ });
+};
+
+function ensureOpacityIsNoChangingAnymore(win) {
+ return new Promise((resolve, reject) => {
+ win.setTimeout(() => {
+ const currOpacity = getOpacity(win);
+ win.setTimeout(() => {
+ if (currOpacity == getOpacity(win)) {
+ resolve();
+ } else {
+ reject('opacity changed, animation is not paused');
+ }
+ }, 200);
+ }, 200);
+ });
+}
+
+function getViewportHeight(win) {
+ return win.document.querySelector('.spacer').offsetHeight;
+};
diff --git a/test/manual/amp-animation/embeds/scrollbound/clock.a4a.html b/test/manual/amp-animation/embeds/scrollbound/clock.a4a.html
index be3b2e7ebad3..7bd50910de4b 100644
--- a/test/manual/amp-animation/embeds/scrollbound/clock.a4a.html
+++ b/test/manual/amp-animation/embeds/scrollbound/clock.a4a.html
@@ -8,12 +8,13 @@
+