From 274d3d23e48323595100a3577763f78454d89009 Mon Sep 17 00:00:00 2001 From: Alan Orozco Date: Mon, 29 Jan 2018 18:27:35 -0600 Subject: [PATCH] Dynamic page scaling in amp-story (#12901) --- extensions/amp-story/0.1/amp-story-page.js | 14 +- extensions/amp-story/0.1/amp-story.js | 6 + extensions/amp-story/0.1/page-scaling.js | 464 +++++++++++++++++++++ tools/experiments/experiments.js | 6 + 4 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 extensions/amp-story/0.1/page-scaling.js diff --git a/extensions/amp-story/0.1/amp-story-page.js b/extensions/amp-story/0.1/amp-story-page.js index 8cf590f9d8102..7e803aa5a9625 100644 --- a/extensions/amp-story/0.1/amp-story-page.js +++ b/extensions/amp-story/0.1/amp-story-page.js @@ -32,8 +32,10 @@ import {upgradeBackgroundAudio} from './audio'; import {EventType, dispatch, dispatchCustom} from './events'; import {AdvancementConfig} from './page-advancement'; import {matches, scopedQuerySelectorAll} from '../../../src/dom'; +import {dev} from '../../../src/log'; import {getLogEntries} from './logging'; import {getMode} from '../../../src/mode'; +import {PageScalingService} from './page-scaling'; import {LoadingSpinner} from './loading-spinner'; import {listen} from '../../../src/event-helper'; import {debounce} from '../../../src/utils/rate-limit'; @@ -198,7 +200,7 @@ export class AmpStoryPage extends AMP.BaseElement { /** @return {!Promise} */ beforeVisible() { this.rewindAllMediaToBeginning_(); - return this.maybeApplyFirstAnimationFrame(); + return this.scale_().then(() => this.maybeApplyFirstAnimationFrame()); } @@ -404,6 +406,14 @@ export class AmpStoryPage extends AMP.BaseElement { return this.animationManager_.applyFirstFrame(); } + /** + * @return {!Promise} + * @private + */ + scale_() { + const storyEl = dev().assertElement(this.element.parentNode); + return PageScalingService.for(storyEl).scale(this.element); + } /** * @param {boolean} isActive @@ -434,10 +444,10 @@ export class AmpStoryPage extends AMP.BaseElement { */ setDistance(distance) { this.element.setAttribute('distance', distance); - this.registerAllMedia_(); if (distance > 0 && distance <= 2) { this.preloadAllMedia_(); + this.scale_(); } } diff --git a/extensions/amp-story/0.1/amp-story.js b/extensions/amp-story/0.1/amp-story.js index ebcd54e865524..ddde08cc38536 100644 --- a/extensions/amp-story/0.1/amp-story.js +++ b/extensions/amp-story/0.1/amp-story.js @@ -250,6 +250,12 @@ export class AmpStory extends AMP.BaseElement { buildCallback() { this.assertAmpStoryExperiment_(); + if (this.isDesktop_()) { + this.element.setAttribute('desktop',''); + } + + this.element.querySelector('amp-story-page').setAttribute('active', ''); + if (this.element.hasAttribute(AMP_STORY_STANDALONE_ATTRIBUTE)) { const html = this.win.document.documentElement; this.mutateElement(() => { diff --git a/extensions/amp-story/0.1/page-scaling.js b/extensions/amp-story/0.1/page-scaling.js new file mode 100644 index 0000000000000..756ec727a0c8e --- /dev/null +++ b/extensions/amp-story/0.1/page-scaling.js @@ -0,0 +1,464 @@ +/** + * Copyright 2018 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 {Services} from '../../../src/services'; +import { + childElementsByTag, + matches, + scopedQuerySelector, +} from '../../../src/dom'; +import {dev, user} from '../../../src/log'; +import {isExperimentOn} from '../../../src/experiments'; +import {px, setImportantStyles} from '../../../src/style'; +import {throttle} from '../../../src/utils/rate-limit'; +import {toArray, toWin} from '../../../src/types'; +import {unscaledClientRect} from './utils'; + + +/** @private @const {number} */ +const MIN_LAYER_WIDTH_PX = 380; + + +/** @private @const {number} */ +const MAX_LAYER_WIDTH_PX = 520; + + +/** @private @const {string} */ +const SCALING_APPLIED_CLASSNAME = 'i-amphtml-story-scaled'; + + +/** @struct @typedef {{factor: number, width: number, height: number}} */ +let TargetDimensionsDef; + + +/** + * @struct + * @typedef {{ + * relativeWidth: number, + * relativeHeight: number, + * matrix: ?Array, + * }} + */ +let ScalableDimensionsDef; + + +/** + * @param {!Element} sizer + * @return {!TargetDimensionsDef} + */ +function targetDimensionsFor(sizer) { + const {width, height} = unscaledClientRect(sizer); + + const ratio = width / height; + + const targetWidth = Math.min(MAX_LAYER_WIDTH_PX, + Math.max(width, Math.max(1, ratio) * MIN_LAYER_WIDTH_PX)); + + const targetHeight = (targetWidth / ratio); + + const factor = width / targetWidth; + + return {factor, width: targetWidth, height: targetHeight}; +} + + +/** + * @param {number} factor + * @param {number} width + * @param {number} height + * @param {!Array} matrix + * @return {!Object} + */ +function scaleTransform(factor, width, height, matrix) { + // TODO(alanorozco, #12934): Translate values are not correctly calculated if + // `scale`, `skew` or `rotate` have been user-defined. + const translateX = width * factor / 2 - width / 2; + const translateY = height * factor / 2 - height / 2; + return [ + matrix[0] * factor, + matrix[1], + matrix[2], + matrix[3] * factor, + matrix[4] + translateX, + matrix[5] + translateY, + ]; +} + + +/** + * @param {!Element} page + * @return {!Array} + */ +function scalableElements(page) { + return toArray(childElementsByTag(page, 'amp-story-grid-layer')); +} + + +/** + * @param {!Element} page + * @return {boolean} + */ +function isScalingEnabled(page) { + // TODO(alanorozco, #12902): Clean up experiment flag. + // NOTE(alanorozco): Experiment flag is temporary. No need to clutter the + // signatures in this function path by adding `win` as a parameter. + const win = toWin(page.ownerDocument.defaultView); + if (isExperimentOn(win, 'amp-story-scaling')) { + return true; + } + return page.getAttribute('scaling') == 'relative'; +} + + +/** + * @param {!Element} page + * @return {boolean} + */ +function isScalingApplied(page) { + return page.classList.contains(SCALING_APPLIED_CLASSNAME); +} + + +/** + * @param {!Element} page + * @param {boolean} isApplied + */ +function markScalingApplied(page, isApplied = true) { + page.classList.toggle(SCALING_APPLIED_CLASSNAME, isApplied); +} + + +/** + * Required for lazy evaluation after resize. + * @param {!Element} page + * @return {boolean} + */ +function withinRange(page) { + return matches(page, '[active], [distance="1"], [desktop] > [distance="2"]'); +} + + +/** + * @param {!Window} win + * @return {boolean} + */ +function isCssZoomSupported(win) { + // IE supports `zoom`, but not `CSS.supports`. + return Services.platformFor(win).isIe() || win.CSS.supports('zoom', '1'); +} + + +/** + * @param {!Window} win + * @return {boolean} + */ +function isCssCustomPropsSupported(win) { + return !Services.platformFor(win).isIe() && win.CSS.supports('(--foo: red)'); +} + + +/** @private {?PageScalingService} */ +let pageScalingService = null; + + +/** + * Service for scaling pages dynamically so their layers will be sized within a + * certain pixel range independent of visual dimensions. + */ +// TODO(alanorozco): Make this part of the runtime layout system to prevent +// FOUC-like jump and allow for SSR. +export class PageScalingService { + /** + * @param {!Window} win + * @param {!Element} rootEl + */ + constructor(win, rootEl) { + /** @private @const {!Window} */ + this.win_ = win; + + /** @private @const {!Element} */ + this.rootEl_ = rootEl; + + /** @private @const */ + this.vsync_ = Services.vsyncFor(win); + + /** @private @const {?Element} */ + // Assumes active page to be determinant of the target size. + this.sizer_ = scopedQuerySelector(rootEl, 'amp-story-page[active]'); + + /** @private {?TargetDimensionsDef} */ + this.targetDimensions_ = null; + + /** @private {!Object>} */ + this.scalableElsDimensions_ = {}; + + Services.viewportForDoc(rootEl).onResize( + throttle(win, () => this.onViewportResize_(), 100)); + } + + /** + * @param {!Element} story + * @return {!PageScalingService} + */ + static for(story) { + // Implemented as singleton for now, should be mapped to story element. + // TODO(alanorozco): Implement mapping to support multiple + // instances in one doc. + const win = toWin(story.ownerDocument.defaultView); + if (!pageScalingService) { + // TODO(alanorozco, #13064): Falling back to transform on iOS + if (!isCssZoomSupported(win) || Services.platformFor(win).isIos()) { + // TODO(alanorozco, #12934): Combine transform matrix. + user().warn('AMP-STORY', + '`amp-story-scaling` using CSS transforms as fallback.', + 'Any `amp-story-grid-layer` with user-defined CSS transforms will', + 'break.', + 'See https://github.com/ampproject/amphtml/issues/12934'); + pageScalingService = new TransformScalingService(win, story); + } else if (isCssCustomPropsSupported(win)) { + pageScalingService = new CssPropsZoomScalingService(win, story); + } else { + pageScalingService = new ZoomScalingService(win, story); + } + } + return pageScalingService; + } + + /** + * @param {!Element} page + * @return {!Promise} + */ + scale(page) { + return Promise.resolve(this.scale_(page)); + } + + /** + * @param {!Element} page + * @return {!Promise|undefined} + * @private + */ + scale_(page) { + if (isScalingApplied(page)) { + return; + } + if (!isScalingEnabled(page)) { + return; + } + if (!withinRange(page)) { + return; + } + return this.vsync_.runPromise({ + measure: state => { + state.targetDimensions = this.measureTargetDimensions_(); + state.scalableElsDimensions = this.getOrMeasureScalableElsFor(page); + }, + mutate: state => { + const {targetDimensions, scalableElsDimensions} = state; + scalableElements(page).forEach((el, i) => { + // `border-box` required since layer now has a width/height set. + setImportantStyles(el, {'box-sizing': 'border-box'}); + setImportantStyles(el, + this.scalingStyles(targetDimensions, scalableElsDimensions[i])); + }); + markScalingApplied(page); + }, + }, /* state */ {}); + } + + /** + * @return {!TargetDimensionsDef} + * @private + */ + measureTargetDimensions_() { + if (!this.targetDimensions_) { + const sizer = dev().assertElement(this.sizer_, 'No sizer.'); + const dimensions = targetDimensionsFor(sizer); + this.targetDimensions_ = dimensions; + this.updateRootProps(dimensions); + } + return /** @type {!TargetDimensionsDef} */ ( + dev().assert(this.targetDimensions_)); + } + + /** + * @param {!Element} page + * @return {!Array} + * @protected + */ + getOrMeasureScalableElsFor(page) { + const pageId = user().assert(page.id, 'No page id.'); + + if (!this.scalableElsDimensions_[pageId]) { + this.scalableElsDimensions_[pageId] = this.measureScalableElsFor(page); + } + + return /** @type {!Array} */ ( + dev().assert(this.scalableElsDimensions_[pageId])); + } + + /** + * Measures scalable elements in a page. + * @param {!Element} page + * @return {!Array} + * @protected + */ + measureScalableElsFor(page) { + const {width, height} = unscaledClientRect(page); + const pageWidth = width; + const pageHeight = height; + return scalableElements(page).map(el => { + const {width, height} = unscaledClientRect(el); + return { + matrix: this.getTransformMatrix(el), + relativeWidth: width / pageWidth, + relativeHeight: height / pageHeight, + }; + }); + } + + /** @private */ + onViewportResize_() { + this.targetDimensions_ = null; + this.vsync_.measure(() => { + this.measureTargetDimensions_(); + }); + this.updatePagesOnResize(); + } + + /** @protected */ + scaleAll() { + const pages = toArray(childElementsByTag(this.rootEl_, 'amp-story-page')); + pages.forEach(page => { + markScalingApplied(page, false); + this.scale_(page); + }); + } + + /** + * Updates properties on root element when target dimensions have been + * re-measured. + * @param {!TargetDimensionsDef} unusedTargetDimensions + */ + updateRootProps(unusedTargetDimensions) { + // Intentionally left blank. + } + + /** @protected */ + updatePagesOnResize() { + // Intentionally left blank. + } + + /** + * Gets an element's transform matrix. + * @param {!Element} unusedEl + * @return {?Array} + */ + getTransformMatrix(unusedEl) { + // Calculating a transform matrix is optional depending on scaling + // implementation. + return null; + } + + /** + * @param {!TargetDimensionsDef} unusedTargetDimensions + * @param {!ScalableDimensionsDef} unusedScalableElDimensions + * @return {!Object} + * @protected + */ + scalingStyles(unusedTargetDimensions, unusedScalableElDimensions) { + dev().assert(false, 'Empty PageScalingService implementation.'); + return {}; + } +} + + +/** Uses CSS zoom as scaling method. */ +class ZoomScalingService extends PageScalingService { + /** @protected */ + updatePagesOnResize() { + this.scaleAll(); + } + + /** @override */ + scalingStyles(targetDimensions, elementDimensions) { + const {width, height, factor} = targetDimensions; + const {relativeWidth, relativeHeight} = elementDimensions; + return { + 'width': px(width * relativeWidth), + 'height': px(height * relativeHeight), + 'zoom': factor, + }; + } +} + + +/** Uses combined CSS transform as scaling method. */ +class TransformScalingService extends PageScalingService { + /** @protected */ + updatePagesOnResize() { + this.scaleAll(); + } + + /** @override */ + getTransformMatrix(unusedEl) { + // TODO(alanorozco, #12934): Implement. + return [1, 0, 0, 1, 0, 0]; + } + + /** @override */ + scalingStyles(targetDimensions, elementDimensions) { + const {width, height, factor} = targetDimensions; + const {relativeWidth, relativeHeight, matrix} = elementDimensions; + const initialMatrix = /** @type {!Array} */ (dev().assert(matrix)); + const transformedMatrix = + scaleTransform(factor, width, height, initialMatrix); + return { + 'width': px(width * relativeWidth), + 'height': px(height * relativeHeight), + 'transform': `matrix(${transformedMatrix.join()})`, + }; + } +} + + +/** Uses CSS zoom and custom CSS properties as scaling method. */ +class CssPropsZoomScalingService extends PageScalingService { + /** @override */ + getOrMeasureScalableElsFor(page) { + // Circumvents element dimensions cache as layers are only mutated once. + return this.measureScalableElsFor(page); + } + + /** @override */ + updateRootProps() { + const {width, height, factor} = this.targetDimensions_; + this.vsync_.mutate(() => { + this.rootEl_.style.setProperty('--i-amphtml-story-width', px(width)); + this.rootEl_.style.setProperty('--i-amphtml-story-height', px(height)); + this.rootEl_.style.setProperty('--i-amphtml-story-factor', + factor.toString()); + }); + } + + /** @override */ + scalingStyles(unusedTargetDimensions, elementDimensions) { + const {relativeWidth, relativeHeight} = elementDimensions; + return { + 'width': `calc(var(--i-amphtml-story-width) * ${relativeWidth})`, + 'height': `calc(var(--i-amphtml-story-height) * ${relativeHeight})`, + 'zoom': 'var(--i-amphtml-story-factor)', + }; + } +} diff --git a/tools/experiments/experiments.js b/tools/experiments/experiments.js index 6123b0b839e6e..5c64176094c01 100644 --- a/tools/experiments/experiments.js +++ b/tools/experiments/experiments.js @@ -254,6 +254,12 @@ const EXPERIMENTS = [ spec: 'https://github.com/ampproject/amphtml/issues/11329', cleanupIssue: 'https://github.com/ampproject/amphtml/issues/11475', }, + { + id: 'amp-story-scaling', + name: 'Scale pages dynamically in amp-story by default', + spec: 'https://github.com/ampproject/amphtml/issues/12902', + cleanupIssue: 'https://github.com/ampproject/amphtml/issues/12902', + }, { id: 'disable-amp-story-desktop', name: 'Disables responsive desktop experience for the amp-story component',