diff --git a/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js b/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js index 38242b314079f..e40b8c93b0685 100644 --- a/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js +++ b/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js @@ -14,210 +14,8 @@ * limitations under the License. */ -import {Observable} from '../../../src/observable'; -import {getService} from '../../../src/service'; -import {isExperimentOn} from '../../../src/experiments'; -import {onDocumentReady} from '../../../src/document-ready'; -import {setStyles} from '../../../src/style'; -import {toArray} from '../../../src/types'; -import {user} from '../../../src/log'; -import {viewportForDoc} from '../../../src/viewport'; -import {vsyncFor} from '../../../src/vsync'; +import {installParallaxForDoc} from '../../../src/service/parallax-impl'; +import {ampdocServiceFor} from '../../../src/ampdoc'; -const ATTR = 'amp-fx-parallax'; -const EXPERIMENT = ATTR; - -/** - * Installs parallax handlers, tracks the previous scroll position and - * implements post-parallax-update scroll hooks. - */ -export class ParallaxService { - /** - * @param {!Window} win - */ - constructor(win) { - /** @private @const {!Window} */ - this.win_ = win; - - /** @private @const {!Observable} */ - this.parallaxObservable_ = new Observable(); - - /** @private {number} */ - this.previousScroll_ = 0; - } - - /** - * Install handlers after the document has loaded. - */ - start_() { - const win = this.win_; - onDocumentReady(win.document, () => { - installParallaxHandlers(win); - }); - } - - /** - * Get the previous scroll value. - * @return {number} - */ - getPreviousScroll() { - return this.previousScroll_; - } - - /** - * Set the previous scroll value. - * @param {number} scroll - */ - setPreviousScroll(scroll) { - this.previousScroll_ = scroll; - } - - /** - * Add listeners to parallax scroll events. - * @param {!function()} cb - */ - addScrollListener(cb) { - this.parallaxObservable_.add(cb); - } - - /** - * Remove listeners from parallax scroll events. - * @param {!function()} cb - */ - removeScrollListener(cb) { - this.parallaxObservable_.remove(cb); - } - - /** - * Alert listeners that a scroll has occurred. - * @param {number} scrollTop - */ - fire(scrollTop) { - this.parallaxObservable_.fire(scrollTop); - } -} - -/** - * Encapsulates and tracks an element's linear parallax effect. - */ -export class ParallaxElement { - /** - * @param {!Element} element The element to give a parallax effect. - * @param {!function(number):string} transform Computes the transform from the position. - */ - constructor(element, transform) { - const factor = element.getAttribute(ATTR); - - /** @private @const {!Element} */ - this.element_ = element; - - /** @private @const {!function(number):string} */ - this.transform_ = transform; - - /** @private @const {number} */ - this.factor_ = (factor ? parseFloat(factor) : 0.5) - 1; - - /** @private {number} */ - this.offset_ = 0; - } - - /** - * Apply the parallax effect to the offset given how much the page - * has moved since the last frame. - * @param {number} delta The movement of the base layer e.g. the page. - */ - update(delta) { - this.offset_ += delta * this.factor_; - setStyles(this.element_, {transform: this.transform_(this.offset_)}); - } - - /** - * True if the element is in the viewport. - * @param {!../../../src/service/viewport-impl.Viewport} viewport - * @return {boolean} - */ - shouldUpdate(viewport) { - const viewportRect = viewport.getRect(); - const elementRect = viewport.getLayoutRect(this.element_); - elementRect.top -= viewportRect.top; - elementRect.bottom = elementRect.top + elementRect.height; - return this.isRectInView_(elementRect, viewportRect.height); - } - - /** - * Check if a rectange is within the viewport. - * @param {!../../../src/layout-rect.LayoutRectDef} rect - * @param {number} viewportHeight - * @private - */ - isRectInView_(rect, viewportHeight) { - return rect.bottom >= 0 && rect.top <= viewportHeight; - } -} - -/** - * Constructs and installs scroll handlers on all [amp-fx-parallax] elements - * in the document. - * @param {!Window} global - */ -function installParallaxHandlers(global) { - const enabled = isExperimentOn(global, EXPERIMENT); - user().assert(enabled, `Experiment "${EXPERIMENT}" is disabled.`); - - const doc = global.document; - const viewport = viewportForDoc(doc); - const parallaxService = getService(global, ATTR); - const vsync = vsyncFor(global); - - const elements = toArray(doc.querySelectorAll(`[${ATTR}]`)); - const parallaxElements = elements.map(e => new ParallaxElement(e, transform)); - const mutate = - parallaxMutate.bind(null, parallaxService, parallaxElements, viewport); - - viewport.onScroll(() => vsync.mutate(mutate)); - mutate(); // initialize the elements with the current scroll position -} - -/** - * Create a value for the CSS transform property given a position. - * @param {number} position - * @return {string} - */ -function transform(position) { - return `translate3d(0,${position.toFixed(2)}px,0)`; -} - -/** - * Update each [amp-fx-parallax] element with the new scroll position. - * Notify any listeners. - * @param {!ParallaxService} parallaxService - * @param {!Array} elements - * @param {!../../../src/service/viewport-impl.Viewport} viewport - */ -function parallaxMutate(parallaxService, elements, viewport) { - const newScrollTop = viewport.getScrollTop(); - const previousScrollTop = parallaxService.getPreviousScroll(); - const delta = previousScrollTop - newScrollTop; - - elements.forEach(element => { - if (!element.shouldUpdate(viewport)) { - return; - } - element.update(delta); - parallaxService.setPreviousScroll(newScrollTop); - }); - - parallaxService.fire(newScrollTop); -} - -/** - * @param {!Window} win - * @return {!ParallaxService} - */ -export function installParallaxService(win) { - return getService(win, ATTR, () => { - return new ParallaxService(win); - }).start_(); -}; - -installParallaxService(AMP.win); +const ampdoc = ampdocServiceFor(AMP.win).getAmpDoc(); +installParallaxForDoc(ampdoc.getRootNode()); diff --git a/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js b/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js index 117166ff06b07..d276c78353e6b 100644 --- a/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js +++ b/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js @@ -15,16 +15,17 @@ */ import {createIframePromise} from '../../../../testing/iframe'; -import {getService} from '../../../../src/service'; -import {installParallaxService} from '../amp-fx-parallax'; import {toggleExperiment} from '../../../../src/experiments'; import {viewportForDoc} from '../../../../src/viewport'; +import {installParallaxForDoc} from '../../../../src/service/parallax-impl'; +import {parallaxForDoc} from '../../../../src/parallax'; +import {vsyncFor} from '../../../../src/vsync'; -describe('amp-fx-parallax', () => { +describes.sandboxed('amp-fx-parallax', {}, () => { const DEFAULT_FACTOR = 1.7; function addTextChildren(iframe) { - return [iframe.doc.createTextNode('This is some parallaxy text.')]; + return [iframe.doc.createTextNode('AMP: Accelerated Mobile Pages')]; } function getAmpParallaxElement(opt_childrenCallback, opt_factor, opt_top) { @@ -35,14 +36,15 @@ describe('amp-fx-parallax', () => { return createIframePromise().then(iframe => { const bodyResizer = iframe.doc.createElement('div'); - bodyResizer.style.height = '400vh'; + bodyResizer.style.height = '4000px'; bodyResizer.style.width = '1px'; iframe.doc.body.appendChild(bodyResizer); - iframe.doc.body.style.position = 'relative'; viewport = viewportForDoc(iframe.win.document); viewport.resize_(); + toggleExperiment(iframe.win, 'amp-fx-parallax', true); + parallaxElement = iframe.doc.createElement('div'); parallaxElement.setAttribute('amp-fx-parallax', factor); if (opt_childrenCallback) { @@ -54,17 +56,20 @@ describe('amp-fx-parallax', () => { const parent = iframe.doc.querySelector('#parent'); parent.appendChild(parallaxElement); - viewport.setScrollTop(top); - - toggleExperiment(iframe.win, 'amp-fx-parallax', true); - installParallaxService(iframe.win); - return { - iframe, - viewport, - element: parallaxElement, - }; - }).then(null, error => { - return Promise.reject({error, parallaxElement}); + installParallaxForDoc(iframe.doc); + + return new Promise(resolve => { + vsyncFor(iframe.win).mutate(() => { + resolve({ + element: parallaxElement, + iframe, + viewport, + }); + }); + viewport.setScrollTop(top); + }); + }).catch(error => { + return Promise.reject({error, parallaxElement, stack: error.stack}); }); } @@ -74,7 +79,7 @@ describe('amp-fx-parallax', () => { return getAmpParallaxElement(addTextChildren) .then(({element, iframe, viewport}) => { - const parallaxService = getService(iframe.win, 'amp-fx-parallax'); + const parallaxService = parallaxForDoc(iframe.doc); const top = element.getBoundingClientRect().top; expect(top).to.equal(viewport.getScrollTop()); @@ -95,7 +100,7 @@ describe('amp-fx-parallax', () => { return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR) .then(({element, iframe, viewport}) => { - const parallaxService = getService(iframe.win, 'amp-fx-parallax'); + const parallaxService = parallaxForDoc(iframe.doc); return new Promise(resolve => { parallaxService.addScrollListener(() => { @@ -114,7 +119,7 @@ describe('amp-fx-parallax', () => { return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR) .then(({element, iframe, viewport}) => { - const parallaxService = getService(iframe.win, 'amp-fx-parallax'); + const parallaxService = parallaxForDoc(iframe.doc); return new Promise(resolve => { parallaxService.addScrollListener(() => { const top = element.getBoundingClientRect().top; @@ -133,7 +138,7 @@ describe('amp-fx-parallax', () => { return getAmpParallaxElement(addTextChildren, factor) .then(({element, iframe, viewport}) => { - const parallaxService = getService(iframe.win, 'amp-fx-parallax'); + const parallaxService = parallaxForDoc(iframe.doc); return new Promise(resolve => { parallaxService.addScrollListener(afterFirstScroll); viewport.setScrollTop(scroll); @@ -158,7 +163,7 @@ describe('amp-fx-parallax', () => { return getAmpParallaxElement(addTextChildren, factor) .then(({element, iframe, viewport}) => { - const parallaxService = getService(iframe.win, 'amp-fx-parallax'); + const parallaxService = parallaxForDoc(iframe.doc); return new Promise(resolve => { parallaxService.addScrollListener(afterFirstScroll); viewport.setScrollTop(10); diff --git a/src/parallax.js b/src/parallax.js new file mode 100644 index 0000000000000..4d8a33d5d8161 --- /dev/null +++ b/src/parallax.js @@ -0,0 +1,27 @@ +/** + * 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 {getExistingServiceForDoc} from './service'; + + +/** + * @param {!Node|!./service/ampdoc-impl.AmpDoc} nodeOrDoc + * @return {!./service/parallax-impl.ParallaxService} + */ +export function parallaxForDoc(nodeOrDoc) { + return /** @type {!./service/parallax-impl.ParallaxService} */ ( + getExistingServiceForDoc(nodeOrDoc, 'amp-fx-parallax')); +}; diff --git a/src/service/parallax-impl.js b/src/service/parallax-impl.js new file mode 100644 index 0000000000000..1d027ecc5f64a --- /dev/null +++ b/src/service/parallax-impl.js @@ -0,0 +1,211 @@ +/** + * 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 {Observable} from '../observable'; +import {fromClassForDoc} from '../service'; +import {isExperimentOn} from '../experiments'; +import {onDocumentReady} from '../document-ready'; +import {setStyles} from '../style'; +import {toArray} from '../types'; +import {user} from '../log'; +import {viewportForDoc} from '../viewport'; +import {vsyncFor} from '../vsync'; + +const ATTR = 'amp-fx-parallax'; +const EXPERIMENT = ATTR; + +/** + * Installs parallax handlers, tracks the previous scroll position and + * implements post-parallax-update scroll hooks. + */ +export class ParallaxService { + /** + * @param {!./ampdoc-impl.AmpDoc} ampdoc + */ + constructor(ampdoc) { + /** @private @const {!Observable} */ + this.parallaxObservable_ = new Observable(); + + /** @private {number} */ + this.previousScroll_ = 0; + + onDocumentReady(ampdoc.win.document, () => { + this.installParallaxHandlers_(ampdoc.win); + }); + } + + + /** + * Constructs and installs scroll handlers on all [amp-fx-parallax] elements + * in the document. + * @param {!Window} global + * @private + */ + installParallaxHandlers_(global) { + const doc = global.document; + const viewport = viewportForDoc(doc); + const vsync = vsyncFor(global); + + const elements = toArray(doc.querySelectorAll(`[${ATTR}]`)); + const parallaxElements = elements.map( + e => new ParallaxElement(e, this.transform_)); + const mutate = + this.parallaxMutate_.bind(this, parallaxElements, viewport); + + viewport.onScroll(() => vsync.mutate(mutate)); + mutate(); // initialize the elements with the current scroll position + } + + /** + * Update each [amp-fx-parallax] element with the new scroll position. + * Notify any listeners. + * @param {!Array} elements + * @param {!./viewport-impl.Viewport} viewport + * @private + */ + parallaxMutate_(elements, viewport) { + const newScrollTop = viewport.getScrollTop(); + const previousScrollTop = this.getPreviousScroll(); + const delta = previousScrollTop - newScrollTop; + + elements.forEach(element => { + if (!element.shouldUpdate(viewport)) { + return; + } + element.update(delta); + this.setPreviousScroll(newScrollTop); + }); + + this.fire(newScrollTop); + } + + /** + * Create a value for the CSS transform property given a position. + * @param {number} position + * @return {string} + */ + transform_(position) { + return `translate3d(0,${position.toFixed(2)}px,0)`; + } + + /** + * Get the previous scroll value. + * @return {number} + */ + getPreviousScroll() { + return this.previousScroll_; + } + + /** + * Set the previous scroll value. + * @param {number} scroll + */ + setPreviousScroll(scroll) { + this.previousScroll_ = scroll; + } + + /** + * Add listeners to parallax scroll events. + * @param {!function()} cb + */ + addScrollListener(cb) { + this.parallaxObservable_.add(cb); + } + + /** + * Remove listeners from parallax scroll events. + * @param {!function()} cb + */ + removeScrollListener(cb) { + this.parallaxObservable_.remove(cb); + } + + /** + * Alert listeners that a scroll has occurred. + * @param {number} scrollTop + */ + fire(scrollTop) { + this.parallaxObservable_.fire(scrollTop); + } +} + +/** + * Encapsulates and tracks an element's linear parallax effect. + */ +export class ParallaxElement { + /** + * @param {!Element} element The element to give a parallax effect. + * @param {!function(number):string} transform Computes the transform from the position. + */ + constructor(element, transform) { + const factor = element.getAttribute(ATTR); + + /** @private @const {!Element} */ + this.element_ = element; + + /** @private @const {!function(number):string} */ + this.transform_ = transform; + + /** @private @const {number} */ + this.factor_ = (factor ? parseFloat(factor) : 0.5) - 1; + + /** @private {number} */ + this.offset_ = 0; + } + + /** + * Apply the parallax effect to the offset given how much the page + * has moved since the last frame. + * @param {number} delta The movement of the base layer e.g. the page. + */ + update(delta) { + this.offset_ += delta * this.factor_; + setStyles(this.element_, {transform: this.transform_(this.offset_)}); + } + + /** + * True if the element is in the viewport. + * @param {!./viewport-impl.Viewport} viewport + * @return {boolean} + */ + shouldUpdate(viewport) { + const viewportRect = viewport.getRect(); + const elementRect = viewport.getLayoutRect(this.element_); + elementRect.top -= viewportRect.top; + elementRect.bottom = elementRect.top + elementRect.height; + return this.isRectInView_(elementRect, viewportRect.height); + } + + /** + * Check if a rectange is within the viewport. + * @param {!../layout-rect.LayoutRectDef} rect + * @param {number} viewportHeight + * @private + */ + isRectInView_(rect, viewportHeight) { + return rect.bottom >= 0 && rect.top <= viewportHeight; + } +} + +/** + * @param {!Node|!./ampdoc-impl.AmpDoc} nodeOrDoc + * @return {!ParallaxService} + */ +export function installParallaxForDoc(nodeOrDoc) { + const enabled = isExperimentOn(global, EXPERIMENT); + user().assert(enabled, `Experiment "${EXPERIMENT}" is disabled.`); + return fromClassForDoc(nodeOrDoc, 'amp-fx-parallax', ParallaxService); +};