blob: 06beb65d6ed8fc53963f19475c77bb311cbaeec8 [file] [log] [blame]
/*
* Copyright 2014 Google Inc.
*
* 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.
*
* Author: jmarantz@google.com (Joshua Marantz)
*/
goog.provide('mob.Layout');
goog.require('goog.array');
goog.require('goog.dom.TagName');
goog.require('goog.object');
goog.require('mob.layoutConstants');
goog.require('mob.layoutUtil');
goog.require('mob.util');
goog.require('mob.util.ElementId');
/**
* Creates a context for PageSpeed mobile layout. The layout runs after the
* DOM is populated with HTML. If there are outstanding XHR requests at the
* time the end-of-body JavaScript is run, then we wait until they quiesce
* before running layout. If new XHRs occur afterward, then we run the
* layout algorithm again when they quiesce, to mobilize any new ajax content.
*
* @param {!mob.Mob} psMob
* @constructor
*/
mob.Layout = function(psMob) {
/**
* Mobilization context.
*
* @private {!mob.Mob}
*/
this.psMob_ = psMob;
/**
* Identifies a set of of element IDs to avoid touching. This is a member
* variable so that other JS files can add their special IDs to our list,
* making it easier to maintain them separately.
*
* @private {!Object.<string, boolean>}
*/
this.dontTouchIds_ = goog.object.createSet(
mob.util.ElementId.NAV_PANEL, mob.util.ElementId.HEADER_BAR,
mob.util.ElementId.SPACER, mob.util.ElementId.LOGO_SPAN);
/**
* Holds a target width in pixels for usable screen content,
* determined from document.documentElement.clientWidth, taking into
* account global padding around the body.
*
* @private @const {number}
*/
this.maxWidth_ = mob.layoutUtil.computeMaxWidth();
/**
* Contains a sequence of operations for mobilizing site layout.
* @private @const {!Array.<!mob.Layout.SequenceStep_>}
*/
this.sequence_ = mob.Layout.constructSequence_();
mob.util.consoleLog('window.mob.Layout.maxWidth=' + this.maxWidth_);
};
/**
* Represents a single step of a multi-step layout operation.
*
* The layout transformations are done in a sequence of phases. We represent
* these in a vector with a function for each phase and the name of the phase,
* which helps us keep a progress-bar updated and write console messages
* showing the timing of each phase. This struct holds a single step in
* the sequence.
*
* @param {function(this:mob.Layout, !Element)} fn
* @param {string} description
* @private @constructor @struct
*/
mob.Layout.SequenceStep_ = function(fn, description) {
/** @type {function(this:mob.Layout, !Element)} */
this.functionObj = fn;
/** @type {string} */
this.description = description;
};
/**
* Adds a new DOM id to the set of IDs that we should not mobilize.
*
* @param {string} id
*/
mob.Layout.prototype.addDontTouchId = function(id) {
this.dontTouchIds_[id] = true;
};
/**
* Executes the multi-step mobile layout transfomation. This should
* be called only after all the background image data has been collected.
*/
mob.Layout.prototype.computeAllSizingAndResynthesize = function() {
if (!document.body) {
return;
}
for (var i = 0; i < this.sequence_.length; ++i) {
var sequenceStep = this.sequence_[i];
sequenceStep.functionObj.call(this, document.body);
this.psMob_.layoutPassDone(sequenceStep.description);
}
};
/**
* Returns the number of passes that the layout engine will make over
* the DOM. This is used for progress-bar computation.
*
* @return {number}
*/
mob.Layout.prototype.getNumberOfPasses = function() {
return this.sequence_.length;
};
/**
* Determines whether this element should not be touched.
*
* @param {?Element} element
* @return {boolean}
*/
mob.Layout.prototype.isDontTouchElement = function(element) {
if (!element) {
return true;
}
var tagName = element.tagName.toUpperCase();
return (mob.layoutConstants.DONT_TOUCH_TAGS[tagName] ||
!!(element.id && this.dontTouchIds_[element.id]));
};
/**
* Calls method fn on every mobilizable child of element.
*
* @param {!Element} element
* @param {function(!Element)} fn
* @private
*/
mob.Layout.prototype.forEachMobilizableChild_ = function(element, fn) {
for (var childElement = element.firstElementChild; childElement;
childElement = childElement.nextElementSibling) {
if (!this.isDontTouchElement(childElement)) {
fn.call(this, childElement);
}
}
};
/**
* Makes PRE tags horizontally scrollable. PRE tags represent a
* particular challenge because (we assume) they are set to PRE because
* you cannot reformat them at all, or shrink sub-elements, or anything,
* so we have to make them scrollable.
*
* @param {!Element} element
* @private
*/
mob.Layout.prototype.scrollWidePreTags_ = function(element) {
if ((element.tagName.toUpperCase() == goog.dom.TagName.PRE) ||
((element.offsetWidth > this.maxWidth_) &&
(window.getComputedStyle(element).getPropertyValue('white-space') ==
'pre'))) {
mob.layoutUtil.makeHorizontallyScrollable(element);
}
this.forEachMobilizableChild_(element,
goog.bind(this.scrollWidePreTags_, this));
};
/**
* Resizes a background image to fit in this.maxWidth_.
*
* See also resizeIfTooWide_, which is focused primarily on tables.
*
* @param {!Element} element
* @param {string} image
* @param {!CSSStyleDeclaration} computedStyle
* @private
*/
mob.Layout.prototype.resizeBackgroundImage_ = function(element, image,
computedStyle) {
var imageSize = this.psMob_.findImageSize(image);
if (imageSize && imageSize.width && imageSize.height &&
!mob.layoutUtil.isProbablyASprite(computedStyle)) {
mob.layoutUtil.resizeBackgroundImage(element, imageSize, computedStyle,
this.maxWidth_);
}
};
/**
* Vertically resizes any containers to meet the needs of their children.
*
* @param {!Element} element
* @private
*/
mob.Layout.prototype.resizeVertically_ = function(element) {
this.resizeVerticallyAndReturnBottom_(element, 0);
};
/**
* Computes whether the element is positioned off the screen.
* @param {!CSSStyleDeclaration} style
* @return {boolean}
*/
mob.layoutUtil.isOffScreen = function(style) {
var top = mob.util.pixelValue(style.top);
var left = mob.util.pixelValue(style.left);
return (((top != null) && (top < -100)) || ((left != null) && (left < -100)));
};
/**
* Computes the lowest bottom (highest number) of all the children,
* and adjusts the height of the div to accommodate the children.
* Returns the height.
*
* @param {!Element} element
* @param {number} parentTop
* @return {?number} the bottom y-position of the element after resizing,
* or null if no resizing was done.
* @private
*/
mob.Layout.prototype.resizeVerticallyAndReturnBottom_ = function(element,
parentTop) {
// Determine the top of the current element. If element isn't a don't-touch
// element, we will try to recalculate its bottom based on the subelements.
var topBottom = mob.layoutUtil.findTopAndBottom(element, parentTop);
if (!topBottom) {
return null;
}
var top = topBottom[0];
var bottom = topBottom[1];
if (this.isDontTouchElement(element)) {
return bottom;
}
bottom = top - 1;
var computedStyle = window.getComputedStyle(element);
if (!computedStyle) {
return null;
}
// Respect any min-height set on the element. Note in particular that we will
// set min-height in this.resizeBackgroundImage_, and we may not be able to
// find the 'natural' height of the object based on its subelements.
var minHeight = mob.layoutUtil.computedDimension(computedStyle, 'min-height');
if (minHeight != null) {
bottom += minHeight;
}
// Iterate over the element's child-elements, scanning for absolutely
// positioned children, and recursively calling this method to get
// the calculated bottom.
var elementBottom = top + element.offsetHeight - 1;
var hasChildrenWithSizing = false;
var hasAbsoluteChildren = false;
var childBottom;
for (var childElement = element.firstElementChild; childElement;
childElement = childElement.nextElementSibling) {
var childComputedStyle = window.getComputedStyle(childElement);
if (childComputedStyle && (childComputedStyle.position == 'absolute') &&
!mob.layoutUtil.isOffScreen(childComputedStyle) &&
(mob.util.pixelValue(childComputedStyle.getPropertyValue('height')) !=
0) &&
(childComputedStyle.getPropertyValue('visibility') != 'hidden')) {
// For some reason, the iframe holding the tweets on
// stevesouders.com comes up as 'absolute', but does not
// appear to behave that way. And it is loaded asynchronously
// (XHR response???) so that it has a height of 0 at the time
// that we are doing our vertical resizes. So our attempts
// to compute the proper size here are futile -- we get the
// wrong answer, and our only hope is to leave the element
// height as 'auto'.
//
//
// Note also that when inspecting the element in chrome dev tools
// the iframe does not have absolute positioning, so maybe both
// that and the height get adjusted in response to an event.
//
// TODO(jmarantz): try to wake up on DOM mutations and fix
// the layout. A problem here is that if the parent div
// is manually sized by the site developer to incorporate
// the eventual size of this absolute child, we will shrink
// it here.
hasAbsoluteChildren = true;
}
childBottom = this.resizeVerticallyAndReturnBottom_(childElement, top);
if (childBottom != null) {
hasChildrenWithSizing = true;
bottom = Math.max(bottom, childBottom);
}
}
if (hasChildrenWithSizing &&
(computedStyle.getPropertyValue('position') == 'fixed')) {
// Don't get any further for position-fixed elements, beyond fixing up
// their children.
//
// In our logo resynthesis completely empties the fixed bar at the top,
// and that bar was causing layout problems because it was relying on
// a margin -- which we squashed -- to avoid having the fixed bar obscure
// the content. In that case, hasChildrenWithSizing==false.
//
// However, other sites may have a fixed menu bar which our navigation
// currently does *not* empty, and contains weird vertical menus which
// stay permenantly over the sides of the main content. We have to avoid
// resizing the fixed parent because that will reserve too much room for
// it and create a big blank area at the top of the screen. In this case,
// hasChildrenWithSizing==true.
return null;
}
// Based on the calculated values from the child-element scan above,
// vertically resize element.
var tagName = element.tagName.toUpperCase();
if (tagName != goog.dom.TagName.BODY) {
var height = elementBottom - top + 1;
if (!hasChildrenWithSizing) {
// Leaf node, such as text or an A tag. The only time we should respect
// the CSS sizing here is if it's a sized IMG tag. Note that IFRAMes are
// already excluded by this.isDontTouchElement above.
if ((tagName != goog.dom.TagName.IMG) && (height > 0) &&
!element.style.backgroundSize) {
mob.layoutUtil.removeProperty(element, 'height');
mob.layoutUtil.setPropertyImportant(element, 'height', 'auto');
if (element.offsetHeight) {
elementBottom = top + element.offsetHeight;
}
}
bottom = elementBottom;
} else if (bottom != elementBottom) {
if (hasAbsoluteChildren) {
height = bottom - top + 1;
mob.layoutUtil.setPropertyImportant(element, 'height',
'' + height + 'px');
} else {
mob.layoutUtil.setPropertyImportant(element, 'height', 'auto');
}
}
}
return bottom;
};
/**
* Shrinks wide foreground images, background images, tables, and
* other flexible-width tags.
*
* @param {!Element} element
* @private
*/
mob.Layout.prototype.resizeIfTooWide_ = function(element) {
// Try to fix lower-level nested nodes that are simply too wide before
// re-arranging higher-level nodes.
this.forEachMobilizableChild_(
element, goog.bind(this.resizeIfTooWide_, this));
if (element.offsetWidth <= this.maxWidth_) {
return;
}
var tagName = element.tagName.toUpperCase();
if (tagName == goog.dom.TagName.TABLE) {
mob.layoutUtil.resizeWideTable(element, this.maxWidth_);
} else if (tagName == goog.dom.TagName.IMG) {
mob.layoutUtil.resizeForegroundImage(element, this.maxWidth_);
} else {
var images = mob.layoutUtil.findBackgroundImages(element);
var computedStyle = window.getComputedStyle(element);
if (images && computedStyle && images.length == 1) {
// TODO(jmarantz): handle case where there is more than one image.
this.resizeBackgroundImage_(element, images[0], computedStyle);
} else if (goog.array.contains(mob.layoutConstants.LAYOUT_CRITICAL,
tagName)) {
mob.layoutUtil.makeHorizontallyScrollable(element);
} else if (mob.layoutConstants.FLEXIBLE_WIDTH_TAGS[tagName]) {
mob.layoutUtil.setPropertyImportant(element, 'max-width', '100%');
mob.layoutUtil.removeProperty(element, 'width');
} else {
mob.util.consoleLog('Punting on resize of ' + tagName +
' which wants to be ' + element.offsetWidth +
' but this.maxWidth_=' + this.maxWidth_);
}
}
};
/**
* Overrides various styles on the DOM, e.g. large margins & padding,
* percentages on left and top, etc. This outer function ensures that
* the body is hidden before recursing through it.
*
* @param {!Element} element
* @private
*/
mob.Layout.prototype.cleanupStyles_ = function(element) {
// Temporarily hide the body to allow computed 'width' to reflect a
// percentage, if it was expressed that way in CSS. If we leave the
// body visible, then the computed width comes out as a pixel value.
// We are trying to eliminate percentage widths to improve the
// appearance of some that had set percentage widths when laying out
// for desktop.
//
// See the 'Notes' section in
// https://developer.mozilla.org/en-US/docs/Web/API/Window.getComputedStyle
//
// TODO(jmarantz): investigate if there is a better way to do this, as
// setting the display to 'none' may force a re-render.
var saveDisplay = document.body.style.display;
document.body.style.display = 'none';
this.cleanupStylesHelper_(element);
document.body.style.display = saveDisplay;
};
/**
* Helper method for mob.Layout.prototype.cleanupStyles_ that
* assumes that body-display has been set to 'none' before calling.
*
* @param {!Element} element
* @private
*/
mob.Layout.prototype.cleanupStylesHelper_ = function(element) {
mob.layoutUtil.wrapTextOnWhitespace(element);
this.forEachMobilizableChild_(element, this.cleanupStylesHelper_);
// After recursing into children, the computed styles on the parent
// can change, and we need the new ones.
var computedStyle = window.getComputedStyle(element);
if (computedStyle) {
mob.layoutUtil.stripPercentDimensions(element, computedStyle);
mob.layoutUtil.trimPaddingAndMargins(element, computedStyle);
}
};
/**
* Repairs images that were squeezed by the browser resizing algorithms due
* to our global CSS max-width:100% setting.
*
* @param {!Element} element
* @private
*/
mob.Layout.prototype.repairDistortedImages_ = function(element) {
this.forEachMobilizableChild_(element, this.repairDistortedImages_);
if (element.tagName.toUpperCase() == goog.dom.TagName.IMG) {
mob.layoutUtil.repairDistortedImages(element);
}
};
/**
* Reorders containers with 'float' elements so they are no longer needed.
* If there are multiple 'float:right' elements, their order is reversed
* in addition to stripping their float attributes.
*
* @param {!Element} element
* @return {string} the position of the element (fixed, absolute, static...)
* @private
*/
mob.Layout.prototype.stripFloats_ = function(element) {
var elementStyle = window.getComputedStyle(element);
var position = elementStyle.getPropertyValue('position');
if (position == 'fixed') {
return 'fixed';
}
if (mob.layoutUtil.isPossiblyASlideShow(element)) {
return position;
}
// Contains nodes that we want to reorder in element, putting
// them at the end of the child-list in reverse order to their
// accumulation here.
var reorderNodes = [];
var previousChild = null;
var previousChildHasNegativeBottomMargin = false;
this.forEachMobilizableChild_(element, function(childElement) {
var childStyle = window.getComputedStyle(childElement);
// Clean up the children first, because they might pick up 'float'
// attributes from their parent. If we clean the float attributes
// from the parent first, then we won't be able to detect it when
// testing the children.
var childPosition = this.stripFloats_(childElement);
if ((childPosition == 'fixed') ||
!childStyle ||
this.isDontTouchElement(childElement)) {
// do nothing
} else {
if ((childPosition == 'absolute') &&
!mob.layoutUtil.isOffScreen(childStyle)) {
mob.layoutUtil.setPropertyImportant(childElement, 'position',
'relative');
}
var floatStyle = childStyle.getPropertyValue('float');
var floatRight = (floatStyle == 'right');
var displayOverride = 'inline-block';
// One pattern seen on the web is to use a sequence of
// elements with style="float:right;clear:right;" to make
// a second column. On mobile, this won't fly because there
// likely won't be room for a second column. However, we
// don't want to reorder the nodes like a sequence of same-line
// "float:right"s. Instead we want to just strip the float.
if (floatRight && (childStyle.getPropertyValue('clear') == 'right')) {
floatRight = false;
displayOverride = 'block';
if (previousChild && previousChildHasNegativeBottomMargin) {
mob.layoutUtil.setPropertyImportant(previousChild, 'margin-bottom',
'0');
}
}
if (floatRight || (floatStyle == 'left') ||
(displayOverride == 'block')) {
// It won't be effective to call style.removeProperty('float'); when
// it's computed from CSS rules, but we can explicitly set it to
// 'none' right on the object, which will override a value in
// inherited from a class.
mob.layoutUtil.setPropertyImportant(childElement, 'float', 'none');
var display = childStyle.getPropertyValue('display');
if (display != 'none') {
// TODO(jmarantz): If we have an invisible block that's
// got a 'float' attribute, then we don't want to make it
// visible now; we just want to strip the 'float'.
mob.layoutUtil.setPropertyImportant(childElement, 'display',
displayOverride);
}
}
if (floatRight) {
reorderNodes.push(childElement);
}
previousChild = childElement;
var marginBottom =
mob.layoutUtil.computedDimension(childStyle, 'margin-bottom');
previousChildHasNegativeBottomMargin =
((marginBottom != null) && (marginBottom < 0));
}
}.bind(this));
var i, child;
for (i = reorderNodes.length - 1; i >= 0; --i) {
child = reorderNodes[i];
element.removeChild(child);
}
for (i = reorderNodes.length - 1; i >= 0; --i) {
child = reorderNodes[i];
element.appendChild(child);
}
return position;
};
/**
* Expands layout-columns to the full width of the mobile screen.
* When a desktop page with multiple columns is transformed into
* single-column mode, width-constraints can get in the way of using
* the available space on the phone. Thus when we are in single
* column mode, we should remove these constraints.
*
* @param {!Element} element
* @private
*/
mob.Layout.prototype.expandColumns_ = function(element) {
var children = this.findLayoutChildren_(element);
// See if a child is positioned to the right or right of it's neighbor. If
// not, we can expand it and its children.
var prevOffsetRight = null;
for (var i = 0; i < children.length; ++i) {
var childElement = children[i];
var next = (i < children.length - 1) ? children[i + 1] : null;
var offsetRight = childElement.offsetLeft + childElement.offsetWidth;
if (((prevOffsetRight == null) ||
(childElement.offsetLeft < prevOffsetRight)) &&
((next == null) || (next.offsetLeft < offsetRight))) {
var style = window.getComputedStyle(childElement);
if (style) {
mob.layoutUtil.removeWidthConstraint(childElement, style);
this.expandColumns_(childElement);
}
}
var attr = element.getAttribute(mob.layoutUtil.NEGATIVE_BOTTOM_MARGIN_ATTR);
if (attr) {
element.removeAttribute(mob.layoutUtil.NEGATIVE_BOTTOM_MARGIN_ATTR);
var computedStyle = window.getComputedStyle(element);
var height = mob.layoutUtil.computedDimension(computedStyle, 'height');
if (height != null) {
mob.layoutUtil.setPropertyImportant(element, 'margin-bottom',
'' + -height + 'px');
}
}
prevOffsetRight = offsetRight;
}
};
/**
* Collects the 'interesting' children of an element for layout purposes.
*
* @param {!Element} element
* @return {!Array.<!Element>} The interesting child elements of element.
* @private
*/
mob.Layout.prototype.findLayoutChildren_ = function(element) {
var children = [];
var elementStyle = window.getComputedStyle(element);
var position = elementStyle.getPropertyValue('position');
if (position == 'fixed') {
return children;
}
// Make an array of all interesting children and their computed styles.
this.forEachMobilizableChild_(element, function(childElement) {
var computedStyle = window.getComputedStyle(childElement);
var childPosition = computedStyle.getPropertyValue('position');
if ((childPosition != 'fixed') &&
(childPosition != 'absolute') ||
(childElement.offsetWidth != 0)) {
children.push(childElement);
}
});
return children;
};
/**
* Holds the sequence of mobilization entry-points. We declare this as
* an array rather than as sequential code so that we can compute how
* many passes there are for progress bar.
*
* @private
* @return {!Array.<!mob.Layout.SequenceStep_>}
*/
mob.Layout.constructSequence_ = function() {
return [
new mob.Layout.SequenceStep_(mob.Layout.prototype.scrollWidePreTags_,
'scroll wide pre-tags'),
new mob.Layout.SequenceStep_(mob.Layout.prototype.stripFloats_,
'string floats'),
new mob.Layout.SequenceStep_(mob.Layout.prototype.cleanupStyles_,
'cleanup styles'),
new mob.Layout.SequenceStep_(mob.Layout.prototype.repairDistortedImages_,
'repair distored images'),
new mob.Layout.SequenceStep_(mob.Layout.prototype.resizeIfTooWide_,
'resize if too wide'),
new mob.Layout.SequenceStep_(mob.Layout.prototype.expandColumns_,
'expand columns'),
new mob.Layout.SequenceStep_(mob.Layout.prototype.resizeVertically_,
'resize vertically')
];
};