diff --git a/build-system/amp4test.js b/build-system/amp4test.js index dbe69213df95..50890ae64932 100644 --- a/build-system/amp4test.js +++ b/build-system/amp4test.js @@ -33,7 +33,7 @@ app.use('/compose-doc', function(req, res) { const experiments = req.query.experiments; let metaTag = ''; let experimentString = ''; - if (!!experiments) { + if (experiments) { metaTag = ''; experimentString = '"' + experiments.split(',').join('","') + '"'; @@ -46,15 +46,16 @@ app.use('/compose-doc', function(req, res) { - ${metaTag} + ${metaTag} ${extensionScripts} + ${req.query.body} diff --git a/build-system/dep-check-config.js b/build-system/dep-check-config.js index a9bf2b6284c7..dd9da10f7ccc 100644 --- a/build-system/dep-check-config.js +++ b/build-system/dep-check-config.js @@ -188,7 +188,7 @@ exports.rules = [ // TODO(@zhouyx, #9213) Remove this item. 'extensions/amp-ad/0.1/amp-ad-xorigin-iframe-handler.js->' + 'src/service/position-observer-impl.js', - 'extensions/amp-animation/0.1/scrollbound-scene.js->' + + 'extensions/amp-position-observer/0.1/amp-position-observer.js->' + 'src/service/position-observer-impl.js', 'extensions/amp-analytics/0.1/amp-analytics.js->' + 'src/service/cid-impl.js', diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index d8af508dfdf3..bc4b81f8078e 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -273,7 +273,7 @@ var forbiddenTerms = { 'src/service/position-observer-impl.js', // TODO(@zhouyx, #9213) Remove this item. 'extensions/amp-ad/0.1/amp-ad-xorigin-iframe-handler.js', - 'extensions/amp-animation/0.1/scrollbound-scene.js', + 'extensions/amp-position-observer/0.1/amp-position-observer.js', 'src/service/video-manager-impl.js', ], }, diff --git a/examples/amp-position-observer.amp.html b/examples/amp-position-observer.amp.html new file mode 100644 index 000000000000..766a564e054c --- /dev/null +++ b/examples/amp-position-observer.amp.html @@ -0,0 +1,164 @@ + + + + + + amp-position-observer + + + + + + + + + + + + + + + + + +
+

amp-position-observer (Experimental)

+

Please ensure
amp-position-observer
experiment flag is enabled first

Running +
AMP.toggleExperiment('amp-position-observer', true);
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` + + + + + + + + + + + + + + + + + + + + + + +
DescriptionMonitors 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 `` +
AvailabilityExperimental
Required Script<script async custom-element="amp-position-observer" src="https://cdn.ampproject.org/v0/amp-position-observer-0.1.js"></script>
Supported Layoutsnodisplay
Examplesamp-position-observer
+ +[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. + +![Scrollbound animation demo](https://user-images.githubusercontent.com/2099009/29105493-e22a6500-7c82-11e7-9f5e-95c33c76f362.gif) + +```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. + +![visibility demo](https://user-images.githubusercontent.com/2099009/29105727-a7d9a80a-7c84-11e7-8d4a-794f38ea5a5c.gif) + + +```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 @@ +