diff --git a/3p/integration.js b/3p/integration.js index eb97c944ad2d..a3af96f4b192 100644 --- a/3p/integration.js +++ b/3p/integration.js @@ -114,6 +114,7 @@ window.draw3p = function(opt_configCallback) { window.context.isMaster = window.context.master == window; window.context.data = data; window.context.noContentAvailable = triggerNoContentAvailable; + window.context.resize = triggerResizeRequest; if (data.type === 'facebook' || data.type === 'twitter') { // Only make this available to selected embeds until the generic solution is @@ -140,6 +141,13 @@ function triggerDimensions(width, height) { }); } +function triggerResizeRequest(width, height) { + nonSensitiveDataPostMessage('embed-size', { + width: width, + height: height + }); +} + function nonSensitiveDataPostMessage(type, opt_object) { if (window.parent == window) { return; // Nothing to do. diff --git a/ads/README.md b/ads/README.md index 5739257ff96a..2e71f2c040b6 100644 --- a/ads/README.md +++ b/ads/README.md @@ -60,6 +60,33 @@ Example usage: }); ``` +### Ad resizing + +Ads can call the special API +`window.context.resize(width, height)` to send a resize request. + +Example of resize request: +```javascript +window.parent.postMessage({ + sentinel: 'amp-3p', + type: 'embed-size', + height: document.body.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-ad` will show an `overflow` element. Clicking on the `overflow` element will immediately +resize the `amp-ad` 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 ad; +- Whether the resize is requested for an ad below the viewport or above the viewport. + + ### Minimizing HTTP requests #### JS reuse across iframes diff --git a/builtins/amp-ad.js b/builtins/amp-ad.js index eeeef2323fc4..612114144d50 100644 --- a/builtins/amp-ad.js +++ b/builtins/amp-ad.js @@ -20,8 +20,9 @@ import {assert} from '../src/asserts'; import {getIframe, prefetchBootstrap} from '../src/3p-frame'; import {IntersectionObserver} from '../src/intersection-observer'; import {isLayoutSizeDefined} from '../src/layout'; -import {listenOnce} from '../src/iframe-helper'; +import {listen, listenOnce} from '../src/iframe-helper'; import {loadPromise} from '../src/event-helper'; +import {log} from '../src/log'; import {registerElement} from '../src/custom-element'; import {timer} from '../src/timer'; @@ -31,6 +32,8 @@ const POSITION_FIXED_TAG_WHITELIST = { 'AMP-LIGHTBOX': true }; +/** @const {string} */ +const TAG_ = 'AmpAd'; /** * @param {!Window} win Destination window for the new element. @@ -110,6 +113,9 @@ export function installAd(win) { /** @private {IntersectionObserver} */ this.intersectionObserver_ = null; + + /** @private @const {boolean} */ + this.isResizable_ = this.element.hasAttribute('resizable'); } /** @@ -217,6 +223,12 @@ export function installAd(win) { assert(!this.isInFixedContainer_, ' is not allowed to be placed in elements with ' + 'position:fixed: %s', this.element); + if (this.isResizable_) { + this.element.setAttribute('scrolling', 'no'); + assert(this.getOverflowElement(), + 'Overflow element must be defined for resizable ads: %s', + this.element); + } if (!this.iframe_) { this.iframe_ = getIframe(this.element.ownerDocument.defaultView, this.element); @@ -227,12 +239,25 @@ export function installAd(win) { // Triggered by context.noContentAvailable() inside the ad iframe. listenOnce(this.iframe_, 'no-content', () => { this.noContentHandler_(); - }, /* opt_is3P */true); + }, /* opt_is3P */ true); // Triggered by context.reportRenderedEntityIdentifier(…) inside the ad // iframe. listenOnce(this.iframe_, 'entity-id', info => { this.element.setAttribute('creative-id', info.id); - }, /* opt_is3P */true); + }, /* opt_is3P */ true); + listen(this.iframe_, 'embed-size', data => { + if (data.width !== undefined) { + this.iframe_.width = data.width; + this.element.setAttribute('width', data.width); + } + if (data.height !== undefined) { + const newHeight = Math.max(this.element./*OK*/offsetHeight + + data.height - this.iframe_./*OK*/offsetHeight, data.height); + this.iframe_.height = data.height; + this.element.setAttribute('height', newHeight); + this.updateHeight_(newHeight); + } + }, /* opt_is3P */ true); } return loadPromise(this.iframe_); } @@ -244,6 +269,21 @@ export function installAd(win) { } } + /** + * 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 ad is not resizable', + this.element); + return; + } + this.attemptChangeHeight(newHeight); + } + /** * Activates the fallback if the ad reports that the ad slot cannot * be filled. diff --git a/builtins/amp-ad.md b/builtins/amp-ad.md index 2a280db0162d..e436cc7c3a3d 100644 --- a/builtins/amp-ad.md +++ b/builtins/amp-ad.md @@ -62,6 +62,25 @@ Most ad networks require further configuration. This can be passed to the networ Optional attribute to pass configuration to the ad as an arbitrarily complex JSON object. The object is passed to the ad as-is with no mangling done on the names. +#### Ad Resizing + +An `amp-ad` must have static layout defined as is the case with any other AMP element. However, +it's possible to resize an `amp-ad` in runtime. To do so: + +1. The `amp-ad` must be defined with `resizable` attribute; +2. The `amp-ad` must have `overflow` child element; +3. The Ad's Iframe document has to send a `embed-size` request as a window message. + +Notice that `resizable` overrides `scrolling` value to `no`. + +Example of `amp-ad` with `overflow` element: +```html + +
Expand!
+
+ #### Placeholder Optionally `amp-ad` supports a child element with the `placeholder` attribute. If supported by the ad network, this element is shown until the ad is available for viewing. diff --git a/test/fixtures/served/iframe.html b/test/fixtures/served/iframe.html index da1027368d8c..4912c7a8ac05 100644 --- a/test/fixtures/served/iframe.html +++ b/test/fixtures/served/iframe.html @@ -7,8 +7,9 @@ window.addEventListener('message', function(event) { if (event.data && event.data.sentinel == 'amp-test') { if (event.data.type == 'requestHeight') { + var sentinel = event.data.is3p ? 'amp-3p': 'amp'; parent./*OK*/postMessage({ - sentinel: 'amp', + sentinel: sentinel, type: 'embed-size', height: event.data.height }, '*'); diff --git a/test/functional/test-amp-ad.js b/test/functional/test-amp-ad.js index 29737f0611f0..a611ee77dcf6 100644 --- a/test/functional/test-amp-ad.js +++ b/test/functional/test-amp-ad.js @@ -40,6 +40,11 @@ describe('amp-ad', () => { for (const key in attributes) { a.setAttribute(key, attributes[key]); } + if (attributes.resizable !== undefined) { + const overflowEl = iframe.doc.createElement('div'); + overflowEl.setAttribute('overflow', ''); + a.appendChild(overflowEl); + } // Make document long. a.style.marginBottom = '1000px'; if (opt_handleElement) { @@ -155,6 +160,72 @@ describe('amp-ad', () => { })).to.be.not.be.rejected; }); + describe('ad resize', () => { + it('should listen for resize events',() => { + const iframeSrc = 'http://iframe.localhost:' + location.port + + '/base/test/fixtures/served/iframe.html'; + return getAd({ + width: 100, + height: 100, + type: 'a9', + src: 'testsrc', + resizable: '' + }, 'https://schema.org').then(element => { + return new Promise((resolve, unusedReject) => { + impl = element.implementation_; + impl.layoutCallback(); + impl.updateHeight_ = newHeight => { + expect(newHeight).to.equal(217); + resolve(impl); + }; + impl.iframe_.onload = function() { + impl.iframe_.contentWindow.postMessage({ + sentinel: 'amp-test', + type: 'requestHeight', + is3p: true, + height: 217 + }, '*'); + }; + impl.iframe_.src = iframeSrc; + }); + }).then(impl => { + expect(impl.iframe_.height).to.equal('217'); + }); + }); + it('should fallback for resize with overflow element',() => { + return getAd({ + width: 100, + height: 100, + type: 'a9', + src: 'testsrc', + resizable: '' + }, 'https://schema.org').then(element => { + impl = element.implementation_; + impl.attemptChangeHeight = sinon.spy(); + impl.changeHeight = sinon.spy(); + impl.updateHeight_(217); + expect(impl.changeHeight.callCount).to.equal(0); + expect(impl.attemptChangeHeight.callCount).to.equal(1); + expect(impl.attemptChangeHeight.firstCall.args[0]).to.equal(217); + }); + }); + it('should not resize a non-resizable ad',() => { + return getAd({ + width: 100, + height: 100, + type: 'a9', + src: 'testsrc' + }, 'https://schema.org').then(element => { + impl = element.implementation_; + impl.attemptChangeHeight = sinon.spy(); + impl.changeHeight = sinon.spy(); + impl.updateHeight_(217); + expect(impl.changeHeight.callCount).to.equal(0); + expect(impl.attemptChangeHeight.callCount).to.equal(0); + }); + }); + }); + describe('ad intersection', () => { let ampAd; @@ -286,7 +357,7 @@ describe('amp-ad', () => { }); }); - it('should collapse when equestChangeHeight succeeds', () => { + it('should collapse when attemptChangeHeight succeeds', () => { return getAd({ width: 300, height: 750,