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