text: /**
   * 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 {Animation} from '../../animation';
  import {Observable} from '../../observable';
  import {Services} from '../../services';
  import {ViewportBindingDef} from './viewport-binding-def';
  import {ViewportBindingIosEmbedWrapper_} from './viewport-binding-ios-embed-wrapper';
  import {ViewportBindingNatural_} from './viewport-binding-natural';
  import {ViewportInterface} from './viewport-interface';
  import {VisibilityState} from '../../visibility-state';
  import {clamp} from '../../utils/math';
  import {
    closestAncestorElementBySelector,
    getVerticalScrollbarWidth,
    isIframed,
  } from '../../dom';
  import {computedStyle, setStyle} from '../../style';
  import {dev, devAssert} from '../../log';
  import {dict} from '../../utils/object';
  import {getFriendlyIframeEmbedOptional} from '../../iframe-helper';
  import {getMode} from '../../mode';
  import {
    getParentWindowFrameElement,
    registerServiceBuilderForDoc,
  } from '../../service';
  import {isExperimentOn} from '../../experiments';
  import {
    layoutRectFromDomRect,
    layoutRectLtwh,
    moveLayoutRect,
  } from '../../layout-rect';
  import {numeric} from '../../transition';
  import {tryResolve} from '../../utils/promise';
  
  const TAG_ = 'Viewport';
  const SCROLL_POS_TO_BLOCK = {
    'top': 'start',
    'center': 'center',
    'bottom': 'end',
  };
  const SMOOTH_SCROLL_DELAY_ = 300;
  
  /**
   * This object represents the viewport. It tracks scroll position, resize
   * and other events and notifies interesting parties when viewport has changed
   * and how.
   *
   * @implements {ViewportInterface}
   */
  export class ViewportImpl {
    /**
     * @param {!../ampdoc-impl.AmpDoc} ampdoc
     * @param {!ViewportBindingDef} binding
     * @param {!../viewer-interface.ViewerInterface} viewer
     */
    constructor(ampdoc, binding, viewer) {
      const {win} = ampdoc;
  
      /** @const {!../ampdoc-impl.AmpDoc} */
      this.ampdoc = ampdoc;
  
      /**
       * Some viewport operations require the global document.
       * @private @const {!Document}
       */
      this.globalDoc_ = this.ampdoc.win.document;
  
      /** @const {!ViewportBindingDef} */
      this.binding_ = binding;
  
      /** @const {!../viewer-interface.ViewerInterface} */
      this.viewer_ = viewer;
  
      /**
       * Used to cache the rect of the viewport.
       * @private {?../../layout-rect.LayoutRectDef}
       */
      this.rect_ = null;
  
      /**
       * Used to cache the size of the viewport. Also used as last known size,
       * so users should call getSize early on to get a value. The timing should
       * be chosen to avoid extra style recalcs.
       * @private {{width: number, height: number}|null}
       */
      this.size_ = null;
  
      /** @private {?number} */
      this./*OK*/ scrollTop_ = null;
  
      /** @private {boolean} */
      this.scrollAnimationFrameThrottled_ = false;
  
      /** @private {?number} */
      this./*OK*/ scrollLeft_ = null;
  
      /** @private {number} */
      this.paddingTop_ = Number(viewer.getParam('paddingTop') || 0);
  
      /** @private {number} */
      this.lastPaddingTop_ = 0;
  
      /** @private {!../timer-impl.Timer} */
      this.timer_ = Services.timerFor(win);
  
      /** @private {!../vsync-impl.Vsync} */
      this.vsync_ = Services.vsyncFor(win);
  
      /** @private {boolean} */
      this.scrollTracking_ = false;
  
      /** @private {Element} */
      this.scrollingElement_ = null;
  
      /** @private {number} */
      this.scrollCount_ = 0;
  
      /** @private @const {!Observable<!./viewport-interface.ViewportChangedEventDef>} */
      this.changeObservable_ = new Observable();
  
      /** @private @const {!Observable} */
      this.scrollObservable_ = new Observable();
  
      /** @private @const {!Observable<!./viewport-interface.ViewportResizedEventDef>} */
      this.resizeObservable_ = new Observable();
  
      /** @private {?Element|undefined} */
      this.viewportMeta_ = undefined;
  
      /** @private {string|undefined} */
      this.originalViewportMetaString_ = undefined;
  
      /** @private {?../fixed-layer.FixedLayer} */
      this.fixedLayer_ = null;
  
      this.viewer_.onMessage('viewport', this.updateOnViewportEvent_.bind(this));
      this.viewer_.onMessage('scroll', this.viewerSetScrollTop_.bind(this));
      this.viewer_.onMessage(
        'disableScroll',
        this.disableScrollEventHandler_.bind(this)
      );
      if (this.viewer_.isEmbedded()) {
        this.binding_.updatePaddingTop(this.paddingTop_);
      }
  
      this.binding_.onScroll(this.scroll_.bind(this));
      this.binding_.onResize(this.resize_.bind(this));
  
      this.onScroll(this.sendScrollMessage_.bind(this));
  
      /** @private {boolean} */
      this.visible_ = false;
      this.ampdoc.onVisibilityChanged(this.updateVisibility_.bind(this));
      this.updateVisibility_();
  
      // Top-level mode classes.
      const globalDocElement = this.globalDoc_.documentElement;
      if (ampdoc.isSingleDoc()) {
        globalDocElement.classList.add('i-amphtml-singledoc');
      }
      if (viewer.isEmbedded()) {
        globalDocElement.classList.add('i-amphtml-embedded');
      } else {
        globalDocElement.classList.add('i-amphtml-standalone');
      }
      if (isIframed(win)) {
        globalDocElement.classList.add('i-amphtml-iframed');
      }
      if (viewer.getParam('webview') === '1') {
        globalDocElement.classList.add('i-amphtml-webview');
      }
  
      // To avoid browser restore scroll position when traverse history
      if (isIframed(win) && 'scrollRestoration' in win.history) {
        win.history.scrollRestoration = 'manual';
      }
  
      // Override global scrollTo if requested.
      if (this.binding_.overrideGlobalScrollTo()) {
        try {
          Object.defineProperty(win, 'scrollTo', {
            value: (x, y) => this.setScrollTop(y),
          });
          ['pageYOffset', 'scrollY'].forEach((prop) => {
            Object.defineProperty(win, prop, {
              get: () => this.getScrollTop(),
            });
          });
        } catch (e) {
          // Ignore errors.
        }
      }
  
      // BF-cache navigation sometimes breaks clicks in an iframe on iOS. See
      // https://github.com/ampproject/amphtml/issues/30838 for more details.
      // The solution is to make a "fake" scrolling API call.
      const isIframedIos = Services.platformFor(win).isIos() && isIframed(win);
      // We dont want to scroll if we're in a shadow doc, so check that we're
      // in a single doc. Fix for
      // https://github.com/ampproject/amphtml/issues/32165.
      if (isIframedIos && this.ampdoc.isSingleDoc()) {
        this.ampdoc.whenReady().then(() => {
          win./*OK*/ scrollTo(-0.1, 0);
        });
      }
    }
  
    /** @override */
    dispose() {
      this.binding_.disconnect();
    }
  
    /** @override */
    ensureReadyForElements() {
      this.binding_.ensureReadyForElements();
    }
  
    /** @private */
    updateVisibility_() {
      const visible = this.ampdoc.isVisible();
      if (visible != this.visible_) {
        this.visible_ = visible;
        if (visible) {
          this.binding_.connect();
          if (this.size_) {
            // If the size has already been intialized, check it again in case
            // the size has changed between `disconnect` and `connect`.
            this.resize_();
          }
          if (this.scrollTop_) {
            // Remeasure scrollTop when resource becomes visible to fix #11983
            this./*OK*/ scrollTop_ = null;
            this.getScrollTop();
          }
        } else {
          this.binding_.disconnect();
        }
      }
    }
  
    /** @override */
    getPaddingTop() {
      return this.paddingTop_;
    }
  
    /** @override */
    getScrollTop() {
      if (this./*OK*/ scrollTop_ == null) {
        this./*OK*/ scrollTop_ = this.binding_.getScrollTop();
      }
      return this./*OK*/ scrollTop_;
    }
  
    /** @override */
    getScrollLeft() {
      if (this./*OK*/ scrollLeft_ == null) {
        this./*OK*/ scrollLeft_ = this.binding_.getScrollLeft();
      }
      return this./*OK*/ scrollLeft_;
    }
  
    /** @override */
    setScrollTop(scrollPos) {
      this./*OK*/ scrollTop_ = null;
      this.binding_.setScrollTop(scrollPos);
    }
  
    /** @override */
    updatePaddingBottom(paddingBottom) {
      this.ampdoc.waitForBodyOpen().then((body) => {
        setStyle(body, 'borderBottom', `${paddingBottom}px solid transparent`);
      });
    }
  
    /** @override */
    getSize() {
      if (this.size_) {
        return this.size_;
      }
      this.size_ = this.binding_.getSize();
      if (this.size_.width == 0 || this.size_.height == 0) {
        // Only report when the visibility is "visible" or "prerender".
        const visibilityState = this.ampdoc.getVisibilityState();
        if (
          visibilityState == VisibilityState.PRERENDER ||
          visibilityState == VisibilityState.VISIBLE
        ) {
          if (Math.random() < 0.01) {
            dev().error(TAG_, 'viewport has zero dimensions');
          }
        }
      }
      return this.size_;
    }
  
    /** @override */
    getHeight() {
      return this.getSize().height;
    }
  
    /** @override */
    getWidth() {
      return this.getSize().width;
    }
  
    /** @override */
    getScrollWidth() {
      return this.binding_.getScrollWidth();
    }
  
    /** @override */
    getScrollHeight() {
      return this.binding_.getScrollHeight();
    }
  
    /** @override */
    getContentHeight() {
      return this.binding_.getContentHeight();
    }
  
    /** @override */
    contentHeightChanged() {
      this.binding_.contentHeightChanged();
    }
  
    /** @override */
    getRect() {
      if (this.rect_ == null) {
        const scrollTop = this.getScrollTop();
        const scrollLeft = this.getScrollLeft();
        const size = this.getSize();
        this.rect_ = layoutRectLtwh(
          scrollLeft,
          scrollTop,
          size.width,
          size.height
        );
      }
      return this.rect_;
    }
  
    /** @override */
    getLayoutRect(el, opt_premeasuredRect) {
      const scrollLeft = this.getScrollLeft();
      const scrollTop = this.getScrollTop();
  
      // Go up the window hierarchy through friendly iframes.
      const frameElement = getParentWindowFrameElement(el, this.ampdoc.win);
      if (frameElement) {
        const b = this.binding_.getLayoutRect(el, 0, 0, opt_premeasuredRect);
        const c = this.binding_.getLayoutRect(
          frameElement,
          scrollLeft,
          scrollTop
        );
        return layoutRectLtwh(
          Math.round(b.left + c.left),
          Math.round(b.top + c.top),
          Math.round(b.width),
          Math.round(b.height)
        );
      }
  
      return this.binding_.getLayoutRect(
        el,
        scrollLeft,
        scrollTop,
        opt_premeasuredRect
      );
    }
  
    /** @override */
    getClientRectAsync(el) {
      const local = this.vsync_.measurePromise(() => {
        return el./*OK*/ getBoundingClientRect();
      });
  
      let root = this.binding_.getRootClientRectAsync();
      const frameElement = getParentWindowFrameElement(el, this.ampdoc.win);
      if (frameElement) {
        root = this.vsync_.measurePromise(() => {
          return frameElement./*OK*/ getBoundingClientRect();
        });
      }
  
      return Promise.all([local, root]).then((values) => {
        const l = values[0];
        const r = values[1];
        if (!r) {
          return layoutRectFromDomRect(l);
        }
        return moveLayoutRect(l, r.left, r.top);
      });
    }
  
    /** @override */
    supportsPositionFixed() {
      return this.binding_.supportsPositionFixed();
    }
  
    /** @override */
    isDeclaredFixed(element) {
      if (!this.fixedLayer_) {
        return false;
      }
      return this.fixedLayer_.isDeclaredFixed(element);
    }
  
    /** @override */
    scrollIntoView(element) {
      if (IS_SXG) {
        element./* OK */ scrollIntoView();
        return Promise.resolve();
      } else {
        return this.getScrollingContainerFor_(element).then((parent) =>
          this.scrollIntoViewInternal_(element, parent)
        );
      }
    }
  
    /**
     * @param {!Element} element
     * @param {!Element} parent
     */
    scrollIntoViewInternal_(element, parent) {
      const elementTop = this.binding_.getLayoutRect(element).top;
      const newScrollTopPromise = tryResolve(() =>
        Math.max(0, elementTop - this.paddingTop_)
      );
  
      newScrollTopPromise.then((newScrollTop) =>
        this.setElementScrollTop_(parent, newScrollTop)
      );
    }
  
    /** @override */
    animateScrollIntoView(element, pos = 'top', opt_duration, opt_curve) {
      if (IS_SXG) {
        return new Promise((resolve, opt_) => {
          element./* OK */ scrollIntoView({
            block: SCROLL_POS_TO_BLOCK[pos],
            behavior: 'smooth',
          });
          setTimeout(resolve, SMOOTH_SCROLL_DELAY_);
        });
      } else {
        devAssert(
          !opt_curve || opt_duration !== undefined,
          "Curve without duration doesn't make sense."
        );
  
        return this.getScrollingContainerFor_(element).then((parent) =>
          this.animateScrollWithinParent(
            element,
            parent,
            dev().assertString(pos),
            opt_duration,
            opt_curve
          )
        );
      }
    }
  
    /** @override */
    animateScrollWithinParent(element, parent, pos, opt_duration, opt_curve) {
      devAssert(
        !opt_curve || opt_duration !== undefined,
        "Curve without duration doesn't make sense."
      );
  
      const elementRect = this.binding_.getLayoutRect(element);
  
      const {height: parentHeight} = this.isScrollingElement_(parent)
        ? this.getSize()
        : this.getLayoutRect(parent);
  
      let offset;
      switch (pos) {
        case 'bottom':
          offset = -parentHeight + elementRect.height;
          break;
        case 'center':
          offset = -parentHeight / 2 + elementRect.height / 2;
          break;
        default:
          offset = 0;
          break;
      }
  
      return this.getElementScrollTop_(parent).then((curScrollTop) => {
        const calculatedScrollTop = elementRect.top - this.paddingTop_ + offset;
        const newScrollTop = Math.max(0, calculatedScrollTop);
        if (newScrollTop == curScrollTop) {
          return;
        }
        return this.interpolateScrollIntoView_(
          parent,
          curScrollTop,
          newScrollTop,
          opt_duration,
          opt_curve
        );
      });
    }
  
    /**
     * @param {!Element} parent
     * @param {number} curScrollTop
     * @param {number} newScrollTop
     * @param {number=} opt_duration
     * @param {string=} curve
     * @private
     */
    interpolateScrollIntoView_(
      parent,
      curScrollTop,
      newScrollTop,
      opt_duration,
      curve = 'ease-in'
    ) {
      const duration =
        opt_duration !== undefined
          ? dev().assertNumber(opt_duration)
          : getDefaultScrollAnimationDuration(curScrollTop, newScrollTop);
  
      /** @const {!TransitionDef<number>} */
      const interpolate = numeric(curScrollTop, newScrollTop);
      return Animation.animate(
        parent,
        (position) => {
          this.setElementScrollTop_(parent, interpolate(position));
        },
        duration,
        curve
      ).thenAlways(() => {
        this.setElementScrollTop_(parent, newScrollTop);
      });
    }
  
    /**
     * @param {!Element} element
     * @return {!Promise<!Element>}
     */
    getScrollingContainerFor_(element) {
      return this.vsync_.measurePromise(
        () =>
          closestAncestorElementBySelector(element, '.i-amphtml-scrollable') ||
          this.binding_.getScrollingElement()
      );
    }
  
    /**
     * @param {!Element} element
     * @param {number} scrollTop
     */
    setElementScrollTop_(element, scrollTop) {
      if (this.isScrollingElement_(element)) {
        this.binding_.setScrollTop(scrollTop);
        return;
      }
      this.vsync_.mutate(() => {
        element./*OK*/ scrollTop = scrollTop;
      });
    }
  
    /**
     * @param {!Element} element
     * @return {!Promise<number>}
     */
    getElementScrollTop_(element) {
      if (this.isScrollingElement_(element)) {
        return tryResolve(() => this.getScrollTop());
      }
      return this.vsync_.measurePromise(() => element./*OK*/ scrollTop);
    }
  
    /**
     * @param {!Element} element
     * @return {boolean}
     */
    isScrollingElement_(element) {
      return element == this.binding_.getScrollingElement();
    }
  
    /** @override */
    getScrollingElement() {
      if (this.scrollingElement_) {
        return this.scrollingElement_;
      }
      return (this.scrollingElement_ = this.binding_.getScrollingElement());
    }
  
    /** @override */
    onChanged(handler) {
      return this.changeObservable_.add(handler);
    }
  
    /** @override */
    onScroll(handler) {
      return this.scrollObservable_.add(handler);
    }
  
    /** @override */
    onResize(handler) {
      return this.resizeObservable_.add(handler);
    }
  
    /** @override */
    enterLightboxMode(opt_requestingElement, opt_onComplete) {
      this.viewer_.sendMessage(
        'requestFullOverlay',
        dict(),
        /* cancelUnsent */ true
      );
  
      this.enterOverlayMode();
      if (this.fixedLayer_) {
        this.fixedLayer_.enterLightbox(opt_requestingElement, opt_onComplete);
      }
  
      if (opt_requestingElement) {
        this.maybeEnterFieLightboxMode(
          dev().assertElement(opt_requestingElement)
        );
      }
  
      return this.binding_.updateLightboxMode(true);
    }
  
    /** @override */
    leaveLightboxMode(opt_requestingElement) {
      this.viewer_.sendMessage(
        'cancelFullOverlay',
        dict(),
        /* cancelUnsent */ true
      );
  
      if (this.fixedLayer_) {
        this.fixedLayer_.leaveLightbox();
      }
      this.leaveOverlayMode();
  
      if (opt_requestingElement) {
        this.maybeLeaveFieLightboxMode(
          dev().assertElement(opt_requestingElement)
        );
      }
  
      return this.binding_.updateLightboxMode(false);
    }
  
    /**
     * @return {boolean}
     * @visibleForTesting
     */
    isLightboxExperimentOn() {
      return isExperimentOn(this.ampdoc.win, 'amp-lightbox-a4a-proto');
    }
  
    /**
     * Enters frame lightbox mode if under a Friendly Iframe Embed.
     * @param {!Element} requestingElement
     * @visibleForTesting
     */
    maybeEnterFieLightboxMode(requestingElement) {
      const fieOptional = this.getFriendlyIframeEmbed_(requestingElement);
  
      if (fieOptional) {
        devAssert(
          this.isLightboxExperimentOn(),
          'Lightbox mode for A4A is only available when ' +
            "'amp-lightbox-a4a-proto' experiment is on"
        );
  
        fieOptional.enterFullOverlayMode();
      }
    }
  
    /**
     * Leaves frame lightbox mode if under a Friendly Iframe Embed.
     * @param {!Element} requestingElement
     * @visibleForTesting
     */
    maybeLeaveFieLightboxMode(requestingElement) {
      const fieOptional = this.getFriendlyIframeEmbed_(requestingElement);
  
      if (fieOptional) {
        devAssert(fieOptional).leaveFullOverlayMode();
      }
    }
  
    /**
     * Get FriendlyIframeEmbed if available.
     * @param {!Element} element Element supposedly inside the FIE.
     * @return {?../../friendly-iframe-embed.FriendlyIframeEmbed}
     * @private
     */
    getFriendlyIframeEmbed_(element) {
      const iframeOptional = getParentWindowFrameElement(
        element,
        this.ampdoc.win
      );
  
      return (
        iframeOptional &&
        getFriendlyIframeEmbedOptional(
          /** @type {!HTMLIFrameElement} */
          (dev().assertElement(iframeOptional))
        )
      );
    }
  
    /** @override */
    enterOverlayMode() {
      this.disableTouchZoom();
      this.disableScroll();
    }
  
    /** @override */
    leaveOverlayMode() {
      this.resetScroll();
      this.restoreOriginalTouchZoom();
    }
  
    /** @override */
    disableScroll() {
      const {win} = this.ampdoc;
      const {documentElement} = win.document;
      let requestedMarginRight;
  
      // Calculate the scrollbar width so we can set it as a right margin. This
      // is so that we do not cause content to shift when we disable scroll on
      // platforms that have a width-taking scrollbar.
      this.vsync_.measure(() => {
        const existingMargin = computedStyle(win, documentElement).marginRight;
        const scrollbarWidth = getVerticalScrollbarWidth(this.ampdoc.win);
  
        requestedMarginRight = parseInt(existingMargin, 10) + scrollbarWidth;
      });
  
      this.vsync_.mutate(() => {
        setStyle(documentElement, 'margin-right', requestedMarginRight, 'px');
        this.binding_.disableScroll();
      });
    }
  
    /** @override */
    resetScroll() {
      const {win} = this.ampdoc;
      const {documentElement} = win.document;
  
      this.vsync_.mutate(() => {
        setStyle(documentElement, 'margin-right', '');
        this.binding_.resetScroll();
      });
    }
  
    /** @override */
    resetTouchZoom() {
      const windowHeight = this.ampdoc.win./*OK*/ innerHeight;
      const documentHeight = this.globalDoc_.documentElement./*OK*/ clientHeight;
      if (windowHeight && documentHeight && windowHeight === documentHeight) {
        // This code only works when scrollbar overlay content and take no space,
        // which is fine on mobile. For non-mobile devices this code is
        // irrelevant.
        return;
      }
      if (this.disableTouchZoom()) {
        this.timer_.delay(() => {
          this.restoreOriginalTouchZoom();
        }, 50);
      }
    }
  
    /** @override */
    disableTouchZoom() {
      const viewportMeta = this.getViewportMeta_();
      if (!viewportMeta) {
        // This should never happen in a valid AMP document, thus shortcircuit.
        return false;
      }
      // Setting maximum-scale=1 and user-scalable=no zooms page back to normal
      // and prohibit further default zooming.
      const newValue = updateViewportMetaString(viewportMeta.content, {
        'maximum-scale': '1',
        'user-scalable': 'no',
      });
      return this.setViewportMetaString_(newValue);
    }
  
    /** @override */
    restoreOriginalTouchZoom() {
      if (this.originalViewportMetaString_ !== undefined) {
        return this.setViewportMetaString_(this.originalViewportMetaString_);
      }
      return false;
    }
  
    /** @override */
    updateFixedLayer() {
      if (!this.fixedLayer_) {
        return Promise.resolve();
      }
      return this.fixedLayer_.update();
    }
  
    /** @override */
    addToFixedLayer(element, opt_forceTransfer) {
      if (!this.fixedLayer_) {
        return Promise.resolve();
      }
      return this.fixedLayer_.addElement(element, opt_forceTransfer);
    }
  
    /** @override */
    removeFromFixedLayer(element) {
      if (!this.fixedLayer_) {
        return;
      }
      this.fixedLayer_.removeElement(element);
    }
  
    /** @override */
    createFixedLayer(constructor) {
      this.fixedLayer_ = new constructor(
        this.ampdoc,
        this.vsync_,
        this.binding_.getBorderTop(),
        this.paddingTop_,
        this.binding_.requiresFixedLayerTransfer()
      );
      this.ampdoc.whenReady().then(() => this.fixedLayer_.setup());
    }
  
    /**
     * Updates touch zoom meta data. Returns `true` if any actual
     * changes have been done.
     * @param {string} viewportMetaString
     * @return {boolean}
     */
    setViewportMetaString_(viewportMetaString) {
      const viewportMeta = this.getViewportMeta_();
      if (viewportMeta && viewportMeta.content != viewportMetaString) {
        dev().fine(TAG_, 'changed viewport meta to:', viewportMetaString);
        viewportMeta.content = viewportMetaString;
        return true;
      }
      return false;
    }
  
    /**
     * @return {?Element}
     * @private
     */
    getViewportMeta_() {
      if (isIframed(this.ampdoc.win)) {
        // An embedded document does not control its viewport meta tag.
        return null;
      }
      if (this.viewportMeta_ === undefined) {
        this.viewportMeta_ = /** @type {?HTMLMetaElement} */ (this.globalDoc_.querySelector(
          'meta[name=viewport]'
        ));
        if (this.viewportMeta_) {
          this.originalViewportMetaString_ = this.viewportMeta_.content;
        }
      }
      return this.viewportMeta_;
    }
  
    /**
     * @param {!JsonObject} data
     * @private
     */
    viewerSetScrollTop_(data) {
      const targetScrollTop = data['scrollTop'];
      this.setScrollTop(targetScrollTop);
    }
  
    /**
     * @param {!JsonObject} data
     * @private
     */
    updateOnViewportEvent_(data) {
      const paddingTop = data['paddingTop'];
      const duration = data['duration'] || 0;
      const curve = data['curve'];
      /** @const {boolean} */
      const transient = data['transient'];
  
      if (paddingTop == undefined || paddingTop == this.paddingTop_) {
        return;
      }
  
      this.lastPaddingTop_ = this.paddingTop_;
      this.paddingTop_ = paddingTop;
  
      if (this.fixedLayer_) {
        const animPromise = this.fixedLayer_.animateFixedElements(
          this.paddingTop_,
          this.lastPaddingTop_,
          duration,
          curve,
          transient
        );
        if (paddingTop < this.lastPaddingTop_) {
          this.binding_.hideViewerHeader(transient, this.lastPaddingTop_);
        } else {
          animPromise.then(() => {
            this.binding_.showViewerHeader(transient, paddingTop);
          });
        }
      }
    }
  
    /**
     * @param {!JsonObject} data
     * @private
     */
    disableScrollEventHandler_(data) {
      if (!!data) {
        this.disableScroll();
      } else {
        this.resetScroll();
      }
    }
  
    /**
     * @param {boolean} relayoutAll
     * @param {number} velocity
     * @private
     */
    changed_(relayoutAll, velocity) {
      const size = this.getSize();
      const scrollTop = this.getScrollTop();
      const scrollLeft = this.getScrollLeft();
      dev().fine(
        TAG_,
        'changed event:',
        'relayoutAll=',
        relayoutAll,
        'top=',
        scrollTop,
        'left=',
        scrollLeft,
        'bottom=',
        scrollTop + size.height,
        'velocity=',
        velocity
      );
      this.changeObservable_.fire({
        relayoutAll,
        top: scrollTop,
        left: scrollLeft,
        width: size.width,
        height: size.height,
        velocity,
      });
    }
  
    /** @private */
    scroll_() {
      this.rect_ = null;
      this.scrollCount_++;
      this.scrollLeft_ = this.binding_.getScrollLeft();
      const newScrollTop = this.binding_.getScrollTop();
      if (newScrollTop < 0) {
        // iOS and some other browsers use negative values of scrollTop for
        // overscroll. Overscroll does not affect the viewport and thus should
        // be ignored here.
        return;
      }
      this.scrollTop_ = newScrollTop;
      if (!this.scrollTracking_) {
        this.scrollTracking_ = true;
        const now = Date.now();
        // Wait 2 frames and then request an animation frame.
        this.timer_.delay(() => {
          this.vsync_.measure(() => {
            this.throttledScroll_(now, newScrollTop);
          });
        }, 36);
      }
      this.scrollObservable_.fire();
    }
  
    /**
     * This method is called about every 3 frames (assuming 60hz) and it
     * is called in a vsync measure task.
     * @param {number} referenceTime Time when the scroll measurement, that
     *     triggered this call made, was made.
     * @param {number} referenceTop Scrolltop at that time.
     * @private
     */
    throttledScroll_(referenceTime, referenceTop) {
      this.scrollTop_ = this.binding_.getScrollTop();
      /**  @const {number} */
      const newScrollTop = this.scrollTop_;
      const now = Date.now();
      let velocity = 0;
      if (now != referenceTime) {
        velocity = (newScrollTop - referenceTop) / (now - referenceTime);
      }
      dev().fine(
        TAG_,
        'scroll: scrollTop=' + newScrollTop + '; velocity=' + velocity
      );
      if (Math.abs(velocity) < 0.03) {
        this.changed_(/* relayoutAll */ false, velocity);
        this.scrollTracking_ = false;
      } else {
        this.timer_.delay(
          () =>
            this.vsync_.measure(
              this.throttledScroll_.bind(this, now, newScrollTop)
            ),
          20
        );
      }
    }
  
    /**
     * Send scroll message via the viewer per animation frame
     * @private
     */
    sendScrollMessage_() {
      if (!this.scrollAnimationFrameThrottled_) {
        this.scrollAnimationFrameThrottled_ = true;
        this.vsync_.measure(() => {
          this.scrollAnimationFrameThrottled_ = false;
          this.viewer_.sendMessage(
            'scroll',
            dict({'scrollTop': this.getScrollTop()}),
            /* cancelUnsent */ true
          );
        });
      }
    }
  
    /** @private */
    resize_() {
      this.rect_ = null;
      const oldSize = this.size_;
      this.size_ = null; // Need to recalc.
      const newSize = this.getSize();
      this.updateFixedLayer().then(() => {
        const widthChanged = !oldSize || oldSize.width != newSize.width;
        this.changed_(/*relayoutAll*/ widthChanged, 0);
        const sizeChanged = widthChanged || oldSize.height != newSize.height;
        if (sizeChanged) {
          this.resizeObservable_.fire({
            relayoutAll: widthChanged,
            width: newSize.width,
            height: newSize.height,
          });
        }
      });
    }
  }
  
  /**
   * Parses viewport meta value. It usually looks like:
   * ```
   * width=device-width,initial-scale=1,minimum-scale=1
   * ```
   * @param {string} content
   * @return {!Object<string, (string|undefined)>}
   * @private Visible for testing only.
   */
  export function parseViewportMeta(content) {
    // Ex: width=device-width,initial-scale=1,minimal-ui
    const params = Object.create(null);
    if (!content) {
      return params;
    }
    const pairs = content.split(/,|;/);
    for (let i = 0; i < pairs.length; i++) {
      const pair = pairs[i];
      const split = pair.split('=');
      const name = split[0].trim();
      let value = split[1];
      value = (value || '').trim();
      if (name) {
        params[name] = value;
      }
    }
    return params;
  }
  
  /**
   * Stringifies viewport meta value based on the provided map. It usually looks
   * like:
   * ```
   * width=device-width,initial-scale=1,minimum-scale=1
   * ```
   * @param {!Object<string, string>} params
   * @return {string}
   * @private Visible for testing only.
   */
  export function stringifyViewportMeta(params) {
    // Ex: width=device-width,initial-scale=1,minimal-ui
    let content = '';
    for (const k in params) {
      if (content.length > 0) {
        content += ',';
      }
      if (params[k]) {
        content += k + '=' + params[k];
      } else {
        content += k;
      }
    }
    return content;
  }
  
  /**
   * This method makes a minimal effort to keep the original viewport string
   * unchanged if in fact none of the values have been updated. Returns the
   * updated string or the `currentValue` if no changes were necessary.
   *
   * @param {string} currentValue
   * @param {!Object<string, string|undefined>} updateParams
   * @return {string}
   * @private Visible for testing only.
   */
  export function updateViewportMetaString(currentValue, updateParams) {
    const params = parseViewportMeta(currentValue);
    let changed = false;
    for (const k in updateParams) {
      if (params[k] !== updateParams[k]) {
        changed = true;
        if (updateParams[k] !== undefined) {
          params[k] = /** @type {string} */ (updateParams[k]);
        } else {
          delete params[k];
        }
      }
    }
    if (!changed) {
      return currentValue;
    }
    return stringifyViewportMeta(params);
  }
  
  /**
   * Calculates a default duration for a scrollTop animation.
   * @param {number} scrollTopA commutative with b.
   * @param {number} scrollTopB commutative with a.
   * @param {number=} max in ms. default 500ms.
   * @return {number}
   */
  function getDefaultScrollAnimationDuration(scrollTopA, scrollTopB, max = 500) {
    // 65% of scroll Δ to ms, eg 1000px -> 650ms, integer between 0 and max
    return Math.floor(clamp(0.65 * Math.abs(scrollTopA - scrollTopB), 0, max));
  }
  
  /**
   * @param {!../ampdoc-impl.AmpDoc} ampdoc
   * @return {!ViewportImpl}
   * @private
   */
  function createViewport(ampdoc) {
    const viewer = Services.viewerForDoc(ampdoc);
    const {win} = ampdoc;
    let binding;
    if (
      ampdoc.isSingleDoc() &&
      getViewportType(win, viewer) == ViewportType.NATURAL_IOS_EMBED &&
      !IS_SXG
    ) {
      binding = new ViewportBindingIosEmbedWrapper_(win);
    } else {
      binding = new ViewportBindingNatural_(ampdoc);
    }
    return new ViewportImpl(ampdoc, binding, viewer);
  }
  
  /**
   * The type of the viewport.
   * @enum {string}
   */
  const ViewportType = {
    /**
     * Viewer leaves sizing and scrolling up to the AMP document's window.
     */
    NATURAL: 'natural',
  
    /**
     * This is AMP-specific type and doesn't come from viewer. This is the type
     * that AMP sets when Viewer has requested "natural" viewport on a iOS
     * device.
     * See:
     * https://github.com/ampproject/amphtml/blob/master/spec/amp-html-layout.md
     */
    NATURAL_IOS_EMBED: 'natural-ios-embed',
  };
  
  /**
   * @param {!Window} win
   * @param {!../viewer-interface.ViewerInterface} viewer
   * @return {string}
   */
  function getViewportType(win, viewer) {
    const isIframedIos = Services.platformFor(win).isIos() && isIframed(win);
  
    // Enable iOS Embedded mode for iframed tests (e.g. integration tests).
    if (getMode(win).test && isIframedIos) {
      return ViewportType.NATURAL_IOS_EMBED;
    }
  
    // Override to ios-embed for iframe-viewer mode.
    if (
      isIframedIos &&
      viewer.isEmbedded() &&
      !viewer.hasCapability('iframeScroll')
    ) {
      return ViewportType.NATURAL_IOS_EMBED;
    }
    return ViewportType.NATURAL;
  }
  
  /**
   * @param {!../ampdoc-impl.AmpDoc} ampdoc
   */
  export function installViewportServiceForDoc(ampdoc) {
    registerServiceBuilderForDoc(
      ampdoc,
      'viewport',
      createViewport,
      /* opt_instantiate */ true
    );
  }
  