diff --git a/css/amp.css b/css/amp.css index 8072243c5561..5ab080e21d5d 100644 --- a/css/amp.css +++ b/css/amp.css @@ -201,6 +201,13 @@ i-amp-scroll-container { display: block !important; } +/** + * `-amp-loading-container`, `-amp-loader` and `-amp-loader-dot` all support + * a "loading indicator" usecase. `-amp-loading-container` is mostly responsible + * for alighning the loading indicator, while `-amp-loader` and + * `-amp-loader-dot` are an implementation for a default loading indicator. The + * default implementation includes the three-dot layout and animation. + */ .-amp-loading-container.amp-hidden { visibility: hidden; } @@ -216,10 +223,6 @@ i-amp-scroll-container { white-space: nowrap; } -/** - * We want to be a bit more specific here as we don't want a global active - * to just trigger the loading animation. - */ .-amp-loader.amp-active .-amp-loader-dot { animation: -amp-loader-dots 2s infinite; } @@ -263,6 +266,20 @@ i-amp-scroll-container { } +/** + * `-amp-overflow` is a support for "overflow" element. This is + * an element shown when more content is available. Typically tapping on this + * element shows the full content. + */ +.-amp-overflow { + z-index: 1; +} + +.-amp-overflow.amp-hidden { + visibility: hidden; +} + + amp-pixel { position: absolute !important; top: 0 !important; diff --git a/extensions/amp-iframe/0.1/amp-iframe.js b/extensions/amp-iframe/0.1/amp-iframe.js index 45414a1e4213..4c9e57a43c7f 100644 --- a/extensions/amp-iframe/0.1/amp-iframe.js +++ b/extensions/amp-iframe/0.1/amp-iframe.js @@ -14,16 +14,20 @@ * limitations under the License. */ +import {childElementByAttr} from '../../../src/dom'; import {getLengthNumeral, isLayoutSizeDefined} from '../../../src/layout'; import {loadPromise} from '../../../src/event-helper'; +import {log} from '../../../src/log'; import {parseUrl} from '../../../src/url'; +/** @const {string} */ +const TAG_ = 'AmpIframe'; /** @type {number} */ var count = 0; /** @const */ -var assert = AMP.assert; +const assert = AMP.assert; class AmpIframe extends AMP.BaseElement { /** @override */ @@ -93,6 +97,16 @@ class AmpIframe extends AMP.BaseElement { this.preconnect.url(this.iframeSrc); } + /** @override */ + buildCallback() { + /** @private {?Element} */ + this.overflowElement_ = childElementByAttr(this.element, 'overflow'); + if (this.overflowElement_) { + this.overflowElement_.classList.add('-amp-overflow'); + this.overflowElement_.classList.toggle('amp-hidden', true); + } + } + /** @override */ layoutCallback() { this.assertPosition(); @@ -100,9 +114,14 @@ class AmpIframe extends AMP.BaseElement { // This failed already, lets not signal another error. return Promise.resolve(); } + var width = this.element.getAttribute('width'); var height = this.element.getAttribute('height'); var iframe = document.createElement('iframe'); + + /** @private @const {!HTMLIFrameElement} */ + this.iframe_ = iframe; + this.applyFillContent(iframe); iframe.width = getLengthNumeral(width); iframe.height = getLengthNumeral(height); @@ -111,6 +130,16 @@ class AmpIframe extends AMP.BaseElement { // Chrome does not reflect the iframe readystate. this.readyState = 'complete'; }; + + /** @private @const {boolean} */ + this.isResizable_ = this.element.hasAttribute('resizable'); + if (this.isResizable_) { + this.element.setAttribute('scrolling', 'no'); + assert(this.overflowElement_, + 'Overflow element must be defined for resizable frames: %s', + this.element); + } + /** @const {!Element} */ this.propagateAttributes( ['frameborder', 'allowfullscreen', 'allowtransparency', 'scrolling'], @@ -118,8 +147,44 @@ class AmpIframe extends AMP.BaseElement { setSandbox(this.element, iframe); iframe.src = this.iframeSrc; this.element.appendChild(makeIOsScrollable(this.element, iframe)); + + listen(iframe, 'embed-size', data => { + if (data.width !== undefined) { + iframe.width = data.width; + this.element.setAttribute('width', data.width); + } + if (data.height !== undefined) { + let newHeight = Math.max(this.element./*OK*/offsetHeight + data.height - + this.iframe_./*OK*/offsetHeight, data.height); + iframe.height = data.height; + this.element.setAttribute('height', newHeight); + this.updateHeight_(newHeight); + } + }); return loadPromise(iframe); } + + /** + * Updates the elements height to accommodate the iframe's requested height. + * @param {number} newHeight + * @private + */ + updateHeight_(newHeight) { + if (!this.isResizable_) { + log.warn(TAG_, + 'ignoring embed-size request because this iframe is not resizable', + this.element); + return; + } + this.requestChangeHeight(newHeight, actualHeight => { + assert(this.overflowElement_); + this.overflowElement_.classList.toggle('amp-hidden', false); + this.overflowElement_.onclick = () => { + this.overflowElement_.classList.toggle('amp-hidden', true); + this.changeHeight(actualHeight); + }; + }); + } }; /** @@ -150,4 +215,31 @@ function makeIOsScrollable(element, iframe) { return iframe; } +/** + * Listens for message from the iframe. + * @param {!Element} iframe + * @param {string} typeOfMessage + * @param {function(!Object)} callback + */ +function listen(iframe, typeOfMessage, callback) { + assert(iframe.src, 'only iframes with src supported'); + let origin = parseUrl(iframe.src).origin; + let win = iframe.ownerDocument.defaultView; + win.addEventListener('message', function(event) { + if (event.origin != origin) { + return; + } + if (event.source != iframe.contentWindow) { + return; + } + if (!event.data || event.data.sentinel != 'amp') { + return; + } + if (event.data.type != typeOfMessage) { + return; + } + callback(event.data); + }); +} + AMP.registerElement('amp-iframe', AmpIframe); diff --git a/extensions/amp-iframe/0.1/test/test-amp-iframe.js b/extensions/amp-iframe/0.1/test/test-amp-iframe.js index d3dfadcbf93c..dbb43a6462ab 100644 --- a/extensions/amp-iframe/0.1/test/test-amp-iframe.js +++ b/extensions/amp-iframe/0.1/test/test-amp-iframe.js @@ -55,6 +55,11 @@ describe('amp-iframe', () => { if (opt_translateY) { i.style.transform = 'translateY(' + opt_translateY + ')'; } + if (attributes.resizable !== undefined) { + let overflowEl = iframe.doc.createElement('div'); + overflowEl.setAttribute('overflow', ''); + i.appendChild(overflowEl); + } iframe.doc.body.appendChild(i); // Wait an event loop for the iframe to be created. return pollForLayout(iframe.win, 1).then(() => { @@ -82,6 +87,16 @@ describe('amp-iframe', () => { }); } + function getAmpIframeObject() { + return getAmpIframe({ + src: iframeSrc, + width: 100, + height: 100 + }).then(amp => { + return amp.container.implementation_; + }); + } + it('should render iframe', () => { return getAmpIframe({ src: iframeSrc, @@ -243,13 +258,74 @@ describe('amp-iframe', () => { }); }); - function getAmpIframeObject() { + it('should listen for resize events', () => { + return getAmpIframe({ + src: iframeSrc, + sandbox: 'allow-scripts allow-same-origin', + width: 100, + height: 100, + resizable: '' + }).then(amp => { + let impl = amp.container.implementation_; + impl.layoutCallback(); + let p = new Promise((resolve, reject) => { + impl.updateHeight_ = newHeight => { + resolve({amp: amp, newHeight: newHeight}); + }; + }); + amp.iframe.contentWindow.postMessage({ + sentinel: 'amp-test', + type: 'requestHeight', + height: 217 + }, '*'); + return p; + }).then(res => { + expect(res.newHeight).to.equal(217); + expect(res.amp.iframe.height).to.equal('217'); + }); + }); + + it('should fallback for resize with overflow element', () => { + return getAmpIframe({ + src: iframeSrc, + sandbox: 'allow-scripts', + width: 100, + height: 100, + resizable: '' + }).then(amp => { + let impl = amp.container.implementation_; + impl.requestChangeHeight = sinon.spy(); + impl.changeHeight = sinon.spy(); + impl.updateHeight_(217); + expect(impl.changeHeight.callCount).to.equal(0); + expect(impl.requestChangeHeight.callCount).to.equal(1); + expect(impl.requestChangeHeight.firstCall.args[0]).to.equal(217); + + let fallback = impl.requestChangeHeight.firstCall.args[1]; + fallback(219); + expect(impl.overflowElement_).to.not.be.null; + expect(impl.overflowElement_).to.have.class('-amp-overflow'); + expect(impl.overflowElement_).to.not.have.class('amp-hidden'); + impl.overflowElement_.onclick(); + expect(impl.overflowElement_).to.have.class('amp-hidden'); + expect(impl.changeHeight.callCount).to.equal(1); + expect(impl.changeHeight.firstCall.args[0]).to.equal(219); + }); + }); + + it('should not resize a non-resizable frame', () => { return getAmpIframe({ src: iframeSrc, + sandbox: 'allow-scripts', width: 100, height: 100 }).then(amp => { - return amp.container.implementation_; + let impl = amp.container.implementation_; + impl.requestChangeHeight = sinon.spy(); + impl.changeHeight = sinon.spy(); + impl.updateHeight_(217); + expect(impl.changeHeight.callCount).to.equal(0); + expect(impl.requestChangeHeight.callCount).to.equal(0); }); - } + }); }); diff --git a/extensions/amp-iframe/amp-iframe.md b/extensions/amp-iframe/amp-iframe.md index 4d3b59621950..96a8e41d82e7 100644 --- a/extensions/amp-iframe/amp-iframe.md +++ b/extensions/amp-iframe/amp-iframe.md @@ -40,3 +40,47 @@ Example: **src, srcdoc, sandbox, frameborder, allowfullscreen, allowtransparency** The attributes above should all behave like they do on standard iframes. + + +#### IFrame Resizing + +An `amp-iframe` must have static layout defined as is the case with any other AMP element. However, +it's possible to resize an `amp-iframe` in runtime. To do so: + +1. The `amp-iframe` must be defined with `resizable` attribute; +2. The `amp-iframe` must have `overflow` child element; +3. The IFrame document has to send a `embed-size` request as a window message. + +Notice that `resizable` overrides `scrolling` value to `no`. + +Example of `amp-iframe` with `overflow` element: +```html + +
Read more!
+
+``` + +Example of IFrame resize request: +```javascript +window.parent./*OK*/postMessage({ + sentinel: 'amp', + type: 'embed-size', + height: document.body./*OK*/scrollHeight +}, '*'); +``` + +Once this message is received the AMP runtime will try to accommodate this request as soon as +possible, but it will take into account where the reader is currently reading, whether the scrolling +is ongoing and any other UX or performance factors. If the runtime cannot satisfy the resize events +the `amp-iframe` will show an `overflow` element. Clicking on the `overflow` element will immediately +resize the `amp-iframe` since it's triggered by a user action. + +Here are some factors that affect how fast the resize will be executed: + +- Whether the resize is triggered by the user action; +- Whether the resize is requested for a currently active IFrame; +- Whether the resize is requested for an IFrame below the viewport or above the viewport. diff --git a/src/base-element.js b/src/base-element.js index 10db3acfbb70..e9c2df3bde04 100644 --- a/src/base-element.js +++ b/src/base-element.js @@ -483,6 +483,21 @@ export class BaseElement { this.resources_.changeHeight(this.element, newHeight); } + /** + * Requests the runtime to update the height of this element to the specified + * value. The runtime will schedule this request and attempt to process it + * as soon as possible. However, unlike in {@link changeHeight}, the runtime + * may refuse to make a change in which case it will call the provided + * fallback with the height value. The fallback is expected to provide the + * reader with the user action to update the height manually. + * @param {number} newHeight + * @param {function(number)} fallback + * @protected + */ + requestChangeHeight(newHeight, fallback) { + this.resources_.requestChangeHeight(this.element, newHeight, fallback); + } + /** * Schedules callback to be complete within the next batch. This call is * intended for heavy DOM mutations that typically cause re-layouts. diff --git a/src/custom-element.js b/src/custom-element.js index 8f930133e6b9..b0b3254a3981 100644 --- a/src/custom-element.js +++ b/src/custom-element.js @@ -488,10 +488,9 @@ export function createAmpElementProto(win, name, implementationClass) { // From the moment height is changed the element becomes fully // responsible for managing its height. Aspect ratio is no longer // preserved. - this.sizerElement_.style.paddingTop = newHeight + 'px'; - } else { - this.style.height = newHeight + 'px'; + this.sizerElement_.style.paddingTop = '0'; } + this.style.height = newHeight + 'px'; }; /** diff --git a/src/focus-history.js b/src/focus-history.js new file mode 100644 index 000000000000..e66a22e2cd00 --- /dev/null +++ b/src/focus-history.js @@ -0,0 +1,124 @@ +/** + * Copyright 2015 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 {timer} from './timer'; + + +/** + * FocusHistory keeps track of recent focused elements. This history can be + * purged using `purgeBefore` method. + */ +export class FocusHistory { + /** + * @param {!Window} win + * @param {number} purgeTimeout + */ + constructor(win, purgeTimeout) { + /** @const {!Window} */ + this.win = win; + + /** @private @const {number} */ + this.purgeTimeout_ = purgeTimeout; + + /** @private @const {!Array} */ + this.history_ = []; + + /** @private @const {function(!Event)} */ + this.captureFocus_ = e => { + if (e.target) { + this.pushFocus_(e.target); + } + }; + /** @private @const {function(!Event)} */ + this.captureBlur_ = e => { + // IFrame elements do not receive `focus` event. An alternative way is + // implemented here. We wait for a blur to arrive on the main window + // and after a short time check which element is active. + timer.delay(() => { + this.pushFocus_(this.win.document.activeElement); + }, 500); + }; + this.win.document.addEventListener('focus', this.captureFocus_, true); + this.win.addEventListener('blur', this.captureBlur_); + } + + /** @private For testing. */ + cleanup_() { + this.win.document.removeEventListener('focus', this.captureFocus_, true); + this.win.removeEventListener('blur', this.captureBlur_); + } + + /** + * @param {!Element} element + * @private + */ + pushFocus_(element) { + let now = timer.now(); + if (this.history_.length == 0 || + this.history_[this.history_.length - 1].el != element) { + this.history_.push({el: element, time: now}); + } else { + this.history_[this.history_.length - 1].time = now; + } + this.purgeBefore(now - this.purgeTimeout_); + } + + /** + * Returns the element that was focused last. + * @return {!Element} + */ + getLast() { + if (this.history_.length == 0) { + return null; + } + return this.history_[this.history_.length - 1].el; + } + + /** + * Removes elements from the history older than the specified time. + * @param {time} time + */ + purgeBefore(time) { + let index = this.history_.length - 1; + for (let i = 0; i < this.history_.length; i++) { + if (this.history_[i].time >= time) { + index = i - 1; + break; + } + } + if (index != -1) { + this.history_.splice(0, index + 1); + } + } + + /** + * Returns `true` if the specified element contains any of the elements in + * the history. + * @param {!Element} element + * @return {boolean} + */ + hasDescendantsOf(element) { + if (this.win.document.activeElement) { + this.pushFocus_(this.win.document.activeElement); + } + for (let i = 0; i < this.history_.length; i++) { + if (element.contains(this.history_[i].el)) { + return true; + } + } + return false; + } +} diff --git a/src/resources.js b/src/resources.js index 232974401eba..f29d6e0ce40e 100644 --- a/src/resources.js +++ b/src/resources.js @@ -14,6 +14,7 @@ * limitations under the License. */ +import {FocusHistory} from './focus-history'; import {Pass} from './pass'; import {assert} from './asserts'; import {expandLayoutRect, layoutRectLtwh, layoutRectsOverlap} from @@ -22,9 +23,9 @@ import {inputFor} from './input'; import {log} from './log'; import {documentStateFor} from './document-state'; import {getService} from './service'; +import {makeBodyVisible} from './styles'; import {reportError} from './error'; import {timer} from './timer'; -import {makeBodyVisible} from './styles'; import {viewerFor} from './viewer'; import {viewportFor} from './viewport'; import {vsync} from './vsync'; @@ -39,6 +40,8 @@ let PRELOAD_TASK_OFFSET_ = 2; let PRIORITY_BASE_ = 10; let PRIORITY_PENALTY_TIME_ = 1000; let POST_TASK_PASS_DELAY_ = 1000; +let MUTATE_DEFER_DELAY_ = 500; +let FOCUS_HISTORY_TIMEOUT_ = 1000 * 60; // 1min /** @@ -103,6 +106,9 @@ export class Resources { /** @private {boolean} */ this.forceBuild_ = false; + /** @private {time} */ + this.lastScrollTime_ = 0; + /** @private {number} */ this.lastVelocity_ = 0; @@ -115,7 +121,10 @@ export class Resources { /** @const {!TaskQueue_} */ this.queue_ = new TaskQueue_(); - /** @private {!Array<{resource: !Resource, newHeight: number}>} */ + /** + * @private {!Array<{resource: !Resource, newHeight: number, + * force: boolean, fallback:?function(number)}>} + */ this.changeHeightRequests_ = []; /** @private {!Array} */ @@ -124,21 +133,28 @@ export class Resources { /** @private {number} */ this.scrollHeight_ = 0; - /** @private {!Viewport} */ + /** @private @const {!Viewport} */ this.viewport_ = viewportFor(this.win); - /** @private {!DocumentState} */ + /** @private @const {!DocumentState} */ this.docState_ = documentStateFor(this.win); + /** @private @const {!FocusHistory} */ + this.activeHistory_ = new FocusHistory(this.win, FOCUS_HISTORY_TIMEOUT_); + /** @private {boolean} */ this.vsyncScheduled_ = false; // When viewport is resized, we have to re-measure all elements. this.viewport_.onChanged(event => { + this.lastScrollTime_ = timer.now(); this.lastVelocity_ = event.velocity; this.relayoutAll_ = this.relayoutAll_ || event.relayoutAll; this.schedulePass(); }); + this.viewport_.onScroll(() => { + this.lastScrollTime_ = timer.now(); + }); // Ensure that we attempt to rebuild things when DOM is ready. this.docState_.onReady(() => { @@ -362,7 +378,25 @@ export class Resources { * @param {number} newHeight */ changeHeight(element, newHeight) { - this.scheduleChangeHeight_(this.getResourceForElement(element), newHeight); + this.scheduleChangeHeight_(this.getResourceForElement(element), newHeight, + /* force */ true, /* fallback */ null); + } + + /** + * Requests the runtime to update the height of this element to the specified + * value. The runtime will schedule this request and attempt to process it + * as soon as possible. However, unlike in {@link changeHeight}, the runtime + * may refuse to make a change in which case it will call the provided + * fallback with the height value. The fallback is expected to provide the + * reader with the user action to update the height manually. + * @param {!Element} element + * @param {number} newHeight + * @param {function(number)} fallback + * @protected + */ + requestChangeHeight(element, newHeight, fallback) { + this.scheduleChangeHeight_(this.getResourceForElement(element), newHeight, + /* force */ false, /* fallback */ fallback); } /** @@ -419,7 +453,8 @@ export class Resources { } let viewportSize = this.viewport_.getSize(); - log.fine(TAG_, 'PASS: at ' + timer.now() + + let now = timer.now(); + log.fine(TAG_, 'PASS: at ' + now + ', visible=', this.visible_, ', forceBuild=', this.forceBuild_, ', relayoutAll=', this.relayoutAll_, @@ -438,9 +473,15 @@ export class Resources { // If viewport size is 0, the manager will wait for the resize event. if (viewportSize.height > 0 && viewportSize.width > 0) { - this.mutateWork_(); + if (this.hasMutateWork_()) { + this.mutateWork_(); + } this.discoverWork_(); let delay = this.work_(); + if (this.hasMutateWork_()) { + // Overflow mutate work. + delay = Math.min(delay, MUTATE_DEFER_DELAY_); + } if (this.visible_) { log.fine(TAG_, 'next pass:', delay); this.schedulePass(delay); @@ -451,11 +492,29 @@ export class Resources { } } + /** + * Returns `true` when there's mutate work currently batched. + * @return {boolean} + * @private + */ + hasMutateWork_() { + return (this.deferredMutates_.length > 0 || + this.changeHeightRequests_.length > 0); + } + /** * Performs pre-discovery mutates. * @private */ mutateWork_() { + // Read all necessary data before mutates. + let now = timer.now(); + let viewportRect = this.viewport_.getRect(); + let isScrollingStopped = (Math.abs(this.lastVelocity_) < 1e-2 && + now - this.lastScrollTime_ > MUTATE_DEFER_DELAY_ || + now - this.lastScrollTime_ > MUTATE_DEFER_DELAY_ * 2); + let offset = 10; + if (this.deferredMutates_.length > 0) { log.fine(TAG_, 'deferred mutates:', this.deferredMutates_.length); let deferredMutates = this.deferredMutates_; @@ -475,22 +534,61 @@ export class Resources { let minTop = -1; for (let i = 0; i < changeHeightRequests.length; i++) { let request = changeHeightRequests[i]; + let resource = request.resource; let box = request.resource.getLayoutBox(); if (box.height == request.newHeight) { // Nothing to do. continue; } - if (box.top >= 0) { - minTop = minTop == -1 ? box.top : Math.min(minTop, box.top); + + // Check resize rules. It will either resize element immediately, or + // wait until scrolling stops or will call the fallback. + let resize = false; + if (request.force || !this.visible_) { + // 1. An immediate execution requested or the document is hidden. + resize = true; + } else if (this.activeHistory_.hasDescendantsOf(resource.element)) { + // 2. Active elements are immediately resized. The assumption is that + // the resize is triggered by the user action or soon after. + resize = true; + } else if (box.bottom >= viewportRect.bottom - offset) { + // 3. Elements under viewport are resized immediately. + resize = true; + } else if (box.bottom <= viewportRect.top + offset) { + // 4. Elements above the viewport can only be resized when scrolling + // has stopped, otherwise defer util next cycle. + if (isScrollingStopped) { + resize = true; + } else { + // Defer till next cycle. + this.changeHeightRequests_.push(request); + } + } else if (request.newHeight < box.height) { + // 5. The new height is smaller than the current one. + // TODO(dvoytenko): Enable immediate resize in this case after + // potential abuse scenarios are considered. + resize = false; + } else { + // 6. Element is in viewport don't resize and try fallback instead. + if (request.fallback) { + request.fallback(request.newHeight); + } + } + + if (resize) { + if (box.top >= 0) { + minTop = minTop == -1 ? box.top : Math.min(minTop, box.top); + } + request.resource.changeHeight(request.newHeight); } - request.resource.changeHeight(request.newHeight); } if (minTop != -1) { this.relayoutTop_ = minTop; } - // TODO(dvoytenko, #367): Explore updating scroll position. + // TODO(dvoytenko): consider scroll adjustments when resizing is done + // above the current scrolling position. } } @@ -796,9 +894,11 @@ export class Resources { * Schedules change of the element's height. * @param {!Resource} resource * @param {number} newHeight + * @param {boolean} force + * @param {?function(number)} fallback * @private */ - scheduleChangeHeight_(resource, newHeight) { + scheduleChangeHeight_(resource, newHeight, force, fallback) { if (resource.getLayoutBox().height == newHeight) { // Nothing to do. return; @@ -813,9 +913,15 @@ export class Resources { } if (request) { request.newHeight = newHeight; + request.force = force || request.force; + request.fallback = fallback || request.fallback; } else { - this.changeHeightRequests_.push( - {resource: resource, newHeight: newHeight}); + this.changeHeightRequests_.push({ + resource: resource, + newHeight: newHeight, + force: force, + fallback: fallback + }); } this.schedulePassVsync(); } diff --git a/src/viewport.js b/src/viewport.js index a8a019a70ec7..33d2dcd0f7b2 100644 --- a/src/viewport.js +++ b/src/viewport.js @@ -89,6 +89,9 @@ export class Viewport { /** @private @const {!Observable} */ this.changeObservable_ = new Observable(); + /** @private @const {!Observable} */ + this.scrollObservable_ = new Observable(); + /** @private {?HTMLMetaElement|undefined} */ this.viewportMeta_ = undefined; @@ -155,6 +158,15 @@ export class Viewport { return this./*OK*/scrollLeft_; } + /** + * Sets the desired scroll position on the viewport. + * @param {number} scrollPos + */ + setScrollTop(scrollPos) { + this./*OK*/scrollTop_ = null; + this.binding_.setScrollTop(scrollPos); + } + /** * Returns the size of the viewport. * @return {!{width: number, height: number}} @@ -222,6 +234,19 @@ export class Viewport { return this.changeObservable_.add(handler); } + /** + * Registers the handler for scroll events. These events DO NOT contain + * scrolling offset and it's discouraged to read scrolling offset in the + * event handler. The primary use case for this handler is to inform that + * scrolling might be going on. To get more information {@link onChanged} + * handler should be used. + * @param {!function()} handler + * @return {!Unlisten} + */ + onScroll(handler) { + return this.scrollObservable_.add(handler); + } + /** * Resets touch zoom to initial scale of 1. */ @@ -331,6 +356,8 @@ export class Viewport { /** @private */ scroll_() { + this.scrollObservable_.fire(); + this.scrollLeft_ = this.binding_.getScrollLeft(); if (this.scrollTracking_) { @@ -354,8 +381,13 @@ export class Viewport { /** @private */ scrollDeferred_() { this.scrollTracking_ = false; - assert(this.scrollTop_ !== null); var newScrollTop = this.binding_.getScrollTop(); + if (this.scrollTop_ === null) { + // If the scrollTop was reset while waiting for the next scroll event + // we have to assume that velocity is 0 - there's no other way we can + // calculate it. + this.scrollTop_ = newScrollTop; + } var now = timer.now(); var velocity = 0; if (now != this.scrollMeasureTime_) { diff --git a/test/fixtures/served/iframe.html b/test/fixtures/served/iframe.html index 8c8eaf6ac6e6..da1027368d8c 100644 --- a/test/fixtures/served/iframe.html +++ b/test/fixtures/served/iframe.html @@ -3,5 +3,18 @@ iframe diff --git a/test/functional/test-custom-element.js b/test/functional/test-custom-element.js index c1088f3f3d17..a16f7dc20877 100644 --- a/test/functional/test-custom-element.js +++ b/test/functional/test-custom-element.js @@ -486,8 +486,8 @@ describe('CustomElement', () => { let element = new ElementClass(); element.sizerElement_ = document.createElement('div'); element.changeHeight(111); - expect(element.sizerElement_.style.paddingTop).to.equal('111px'); - expect(element.style.height).to.equal(''); + expect(parseInt(element.sizerElement_.style.paddingTop, 10)).to.equal(0); + expect(element.style.height).to.equal('111px'); }); diff --git a/test/functional/test-focus-history.js b/test/functional/test-focus-history.js new file mode 100644 index 000000000000..94f7900c8c34 --- /dev/null +++ b/test/functional/test-focus-history.js @@ -0,0 +1,173 @@ +/** + * Copyright 2015 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 {FocusHistory} from '../../src/focus-history'; +import * as sinon from 'sinon'; + + +describe('FocusHistory', () => { + + let sandbox; + let clock; + let testDoc; + let eventListeners; + let testWindow; + let windowEventListeners; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + clock = sandbox.useFakeTimers(); + + eventListeners = {}; + testDoc = { + addEventListener: (eventType, handler, capture) => { + if (!capture) { + throw new Error('the focus listener must be capture'); + } + eventListeners[eventType] = handler; + } + }; + + windowEventListeners = {}; + testWindow = { + document: testDoc, + addEventListener: (eventType, handler) => { + windowEventListeners[eventType] = handler; + } + }; + focusHistory = new FocusHistory(testWindow, 10000); + }); + + afterEach(() => { + clock.restore(); + clock = null; + sandbox.restore(); + sandbox = null; + }); + + it('should subscribe to focus events', () => { + expect(eventListeners['focus']).to.not.be.undefined; + expect(windowEventListeners['blur']).to.not.be.undefined; + expect(focusHistory.getLast()).to.be.null; + }); + + it('should push focused elements with timestamp', () => { + let el1 = document.createElement('div'); + let el2 = document.createElement('div'); + eventListeners['focus']({target: el1}); + clock.tick(100); + eventListeners['focus']({target: el2}); + + expect(focusHistory.getLast()).to.equal(el2); + expect(focusHistory.history_.length).to.equal(2); + expect(focusHistory.history_[0]).to.deep.equal({el: el1, time: 0}); + expect(focusHistory.history_[1]).to.deep.equal({el: el1, time: 100}); + }); + + it('should push focused elements with timestamp after window.blur', () => { + windowEventListeners['blur']({}); + expect(focusHistory.history_.length).to.equal(0); + + let el1 = document.createElement('div'); + testDoc.activeElement = el1; + clock.tick(1000); + expect(focusHistory.history_.length).to.equal(1); + expect(focusHistory.history_[0].el).to.deep.equal(el1); + }); + + it('should push and purge', () => { + let el1 = document.createElement('div'); + let el2 = document.createElement('div'); + eventListeners['focus']({target: el1}); + clock.tick(100000); + eventListeners['focus']({target: el2}); + + expect(focusHistory.getLast()).to.equal(el2); + expect(focusHistory.history_.length).to.equal(1); + expect(focusHistory.history_[0]).to.deep.equal({el: el1, time: 100000}); + }); + + it('should replace second push with a new timestamp', () => { + let el1 = document.createElement('div'); + let el2 = document.createElement('div'); + eventListeners['focus']({target: el1}); + clock.tick(100); + eventListeners['focus']({target: el2}); + expect(focusHistory.history_.length).to.equal(2); + + clock.tick(100); + eventListeners['focus']({target: el2}); + expect(focusHistory.history_.length).to.equal(2); + expect(focusHistory.getLast()).to.equal(el2); + expect(focusHistory.history_[0]).to.deep.equal({el: el1, time: 0}); + expect(focusHistory.history_[1]).to.deep.equal({el: el1, time: 200}); + }); + + it('should purge elements before timestamp', () => { + let el1 = document.createElement('div'); + let el2 = document.createElement('div'); + clock.tick(100); + eventListeners['focus']({target: el1}); // time=100 + clock.tick(100); + eventListeners['focus']({target: el2}); // time=200 + + focusHistory.purgeBefore(50); + expect(focusHistory.history_.length).to.equal(2); + + focusHistory.purgeBefore(100); + expect(focusHistory.history_.length).to.equal(2); + + focusHistory.purgeBefore(101); + expect(focusHistory.history_.length).to.equal(1); + expect(focusHistory.history_[0].el).to.equal(el2); + + focusHistory.purgeBefore(201); + expect(focusHistory.history_.length).to.equal(0); + }); + + it('should return false when nothing matches descendants', () => { + let el0 = document.createElement('div'); + expect(focusHistory.hasDescendantsOf(el0)).to.be.false; + + eventListeners['focus']({target: document.createElement('div')}); + expect(focusHistory.hasDescendantsOf(el0)).to.be.false; + }); + + it('should check active element for descendants', () => { + let el0 = document.createElement('div'); + let el01 = document.createElement('div'); + el0.appendChild(el01); + + testDoc.activeElement = el0; + expect(focusHistory.hasDescendantsOf(el0)).to.be.true; + expect(focusHistory.hasDescendantsOf(document.createElement('div'))).to + .be.false; + + testDoc.activeElement = el01; + expect(focusHistory.hasDescendantsOf(el0)).to.be.true; + }); + + it('should check history descendants', () => { + let el0 = document.createElement('div'); + let el01 = document.createElement('div'); + el0.appendChild(el01); + eventListeners['focus']({target: el01}); + expect(focusHistory.hasDescendantsOf(el0)).to.be.true; + expect(focusHistory.hasDescendantsOf(el01)).to.be.true; + expect(focusHistory.hasDescendantsOf(document.createElement('div'))).to + .be.false; + }); +}); diff --git a/test/functional/test-resources.js b/test/functional/test-resources.js index ed126917b900..c6053acf593b 100644 --- a/test/functional/test-resources.js +++ b/test/functional/test-resources.js @@ -359,7 +359,8 @@ describe('Resources changeHeight', () => { applySizesAndMediaQuery: () => {}, viewportCallback: sinon.spy(), prerenderAllowed: () => true, - isRelayoutNeeded: () => true + isRelayoutNeeded: () => true, + contains: otherElement => false }; } @@ -400,27 +401,36 @@ describe('Resources changeHeight', () => { }); it('should schedule separate requests', () => { - resources.scheduleChangeHeight_(resource1, 111); - resources.scheduleChangeHeight_(resource2, 222); + let fallback2 = () => {}; + resources.scheduleChangeHeight_(resource1, 111, false, null); + resources.scheduleChangeHeight_(resource2, 222, true, fallback2); expect(resources.changeHeightRequests_.length).to.equal(2); expect(resources.changeHeightRequests_[0].resource).to.equal(resource1); expect(resources.changeHeightRequests_[0].newHeight).to.equal(111); + expect(resources.changeHeightRequests_[0].force).to.equal(false); + expect(resources.changeHeightRequests_[0].fallback).to.equal(null); + expect(resources.changeHeightRequests_[1].resource).to.equal(resource2); expect(resources.changeHeightRequests_[1].newHeight).to.equal(222); + expect(resources.changeHeightRequests_[1].force).to.equal(true); + expect(resources.changeHeightRequests_[1].fallback).to.equal(fallback2); }); it('should only schedule latest request for the same resource', () => { - resources.scheduleChangeHeight_(resource1, 111); - resources.scheduleChangeHeight_(resource1, 222); + let fallback1 = () => {}; + resources.scheduleChangeHeight_(resource1, 111, true, fallback1); + resources.scheduleChangeHeight_(resource1, 222, false, null); expect(resources.changeHeightRequests_.length).to.equal(1); expect(resources.changeHeightRequests_[0].resource).to.equal(resource1); expect(resources.changeHeightRequests_[0].newHeight).to.equal(222); + expect(resources.changeHeightRequests_[0].force).to.equal(true); + expect(resources.changeHeightRequests_[0].fallback).to.equal(fallback1); }); it('should NOT change height if it didn\'t change', () => { - resources.scheduleChangeHeight_(resource1, 100); + resources.scheduleChangeHeight_(resource1, 100, true, null); resources.mutateWork_(); expect(resources.relayoutTop_).to.equal(-1); expect(resources.changeHeightRequests_.length).to.equal(0); @@ -428,7 +438,7 @@ describe('Resources changeHeight', () => { }); it('should change height', () => { - resources.scheduleChangeHeight_(resource1, 111); + resources.scheduleChangeHeight_(resource1, 111, true, null); resources.mutateWork_(); expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); expect(resources.changeHeightRequests_.length).to.equal(0); @@ -437,11 +447,87 @@ describe('Resources changeHeight', () => { }); it('should pick the smallest relayoutTop', () => { - resources.scheduleChangeHeight_(resource2, 111); - resources.scheduleChangeHeight_(resource1, 111); + resources.scheduleChangeHeight_(resource2, 111, true, null); + resources.scheduleChangeHeight_(resource1, 111, true, null); resources.mutateWork_(); expect(resources.relayoutTop_).to.equal(resource1.layoutBox_.top); }); + + describe('requestChangeHeight rules when element is in viewport', () => { + beforeEach(() => { + viewportMock.expects('getRect').returns( + {top: 0, left: 0, right: 100, bottom: 200}).once(); + resource1.layoutBox_ = {top: 10, left: 0, right: 100, bottom: 50, + height: 50}; + }); + + it('should NOT change height and calls fallback', () => { + let fallback = sinon.spy(); + resources.scheduleChangeHeight_(resource1, 111, false, fallback); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(0); + expect(resource1.changeHeight.callCount).to.equal(0); + expect(fallback.callCount).to.equal(1); + expect(fallback.firstCall.args[0]).to.equal(111); + }); + + it('should NOT change height and no fallback', () => { + resources.scheduleChangeHeight_(resource1, 111, false, null); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(0); + expect(resource1.changeHeight.callCount).to.equal(0); + }); + + it('should change height when new height is lower', () => { + resources.scheduleChangeHeight_(resource1, 10, false, null); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(0); + expect(resource1.changeHeight.callCount).to.equal(0); + }); + + it('should change height when forced', () => { + resources.scheduleChangeHeight_(resource1, 111, true, null); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(0); + expect(resource1.changeHeight.callCount).to.equal(1); + }); + + it('should change height when document is invisible', () => { + resources.visible_ = false; + resources.scheduleChangeHeight_(resource1, 111, false, null); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(0); + expect(resource1.changeHeight.callCount).to.equal(1); + }); + + it('should change height when active', () => { + resource1.element.contains = () => true; + resources.scheduleChangeHeight_(resource1, 111, false, null); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(0); + expect(resource1.changeHeight.callCount).to.equal(1); + }); + + it('should change height when below the viewport', () => { + resource1.layoutBox_ = {top: 10, left: 0, right: 100, bottom: 1050, + height: 50}; + resources.scheduleChangeHeight_(resource1, 111, false, null); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(0); + expect(resource1.changeHeight.callCount).to.equal(1); + }); + + it('should defer when above the viewport and scrolling on', () => { + resource1.layoutBox_ = {top: -1200, left: 0, right: 100, bottom: -1050, + height: 50}; + resources.lastVelocity_ = 10; + resources.lastScrollTime_ = new Date().getTime(); + resources.scheduleChangeHeight_(resource1, 111, false, null); + resources.mutateWork_(); + expect(resources.changeHeightRequests_.length).to.equal(1); + expect(resource1.changeHeight.callCount).to.equal(0); + }); + }); }); diff --git a/test/functional/test-viewport.js b/test/functional/test-viewport.js index 32137383ed03..fede11ed735a 100644 --- a/test/functional/test-viewport.js +++ b/test/functional/test-viewport.js @@ -149,6 +149,36 @@ describe('Viewport', () => { expect(changeEvent.velocity).to.be.closeTo(0.002, 1e-4); }); + it('should defer scroll events and react to reset of scroll pos', () => { + let changeEvent = null; + viewport.onChanged(event => { + changeEvent = event; + }); + viewer.getScrollTop = () => {return 34;}; + viewerViewportHandler(); + expect(changeEvent).to.equal(null); + + // Not enough time past. + clock.tick(100); + viewer.getScrollTop = () => {return 35;}; + viewerViewportHandler(); + expect(changeEvent).to.equal(null); + + // Reset and wait a bit more time. + viewport./*OK*/scrollTop_ = null; + clock.tick(750); + expect(changeEvent).to.not.equal(null); + expect(changeEvent.relayoutAll).to.equal(false); + expect(changeEvent.velocity).to.equal(0); + }); + + it('should update scroll pos and reset cache', () => { + let bindingMock = sandbox.mock(binding); + bindingMock.expects('setScrollTop').withArgs(117).once(); + viewport.setScrollTop(117); + expect(viewport./*OK*/scrollTop_).to.be.null; + }); + it('should change scrollTop for scrollIntoView and respect padding', () => { let element = document.createElement('div'); let bindingMock = sandbox.mock(binding);