/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Class to encapsulate an editable field that blends in with
 * the style of the page. The field can be fixed height, grow with its
 * contents, or have a min height after which it grows to its contents.
 * This is a goog.editor.Field, but with blending and sizing capabilities,
 * and avoids using an iframe whenever possible.
 *
 * @see ../demos/editor/seamlessfield.html
 */


goog.provide('goog.editor.SeamlessField');

goog.require('goog.cssom.iframe.style');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.dom.safe');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Field');
goog.require('goog.editor.icontent');
goog.require('goog.editor.icontent.FieldFormatInfo');
goog.require('goog.editor.icontent.FieldStyleInfo');
goog.require('goog.editor.node');
goog.require('goog.events');
goog.require('goog.html.SafeHtml');
goog.require('goog.log');
goog.require('goog.style');



/**
 * This class encapsulates an editable field that blends in with the
 * surrounding page.
 * To see events fired by this object, please see the base class.
 *
 * @param {string} id An identifer for the field. This is used to find the
 *     field and the element associated with this field.
 * @param {Document=} opt_doc The document that the element with the given
 *     id can be found it.
 * @constructor
 * @extends {goog.editor.Field}
 */
goog.editor.SeamlessField = function(id, opt_doc) {
  'use strict';
  goog.editor.Field.call(this, id, opt_doc);
};
goog.inherits(goog.editor.SeamlessField, goog.editor.Field);


/**
 * @override
 */
goog.editor.SeamlessField.prototype.logger =
    goog.log.getLogger('goog.editor.SeamlessField');

// Functions dealing with field sizing.


/**
 * The key used for listening for the "dragover" event.
 * @type {goog.events.Key}
 * @private
 */
goog.editor.SeamlessField.prototype.listenForDragOverEventKey_;


/**
 * The key used for listening for the iframe "load" event.
 * @type {goog.events.Key}
 * @private
 */
goog.editor.SeamlessField.prototype.listenForIframeLoadEventKey_;


/**
 * Sets the min height of this editable field's iframe. Only used in growing
 * mode when an iframe is used. This will cause an immediate field sizing to
 * update the field if necessary based on the new min height.
 * @param {number} height The min height specified as a number of pixels,
 *    e.g., 75.
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.SeamlessField.prototype.setMinHeight = function(height) {
  'use strict';
  if (height == this.minHeight_) {
    // Do nothing if the min height isn't changing.
    return;
  }
  /** @suppress {strictMissingProperties} Added to tighten compiler checks */
  this.minHeight_ = height;
  if (this.usesIframe()) {
    this.doFieldSizingGecko();
  }
};


/**
 * Whether the field should be rendered with a fixed height, or should expand
 * to fit its contents.
 * @type {boolean}
 * @private
 */
goog.editor.SeamlessField.prototype.isFixedHeight_ = false;


/**
 * Whether the fixed-height handling has been overridden manually.
 * @type {boolean}
 * @private
 */
goog.editor.SeamlessField.prototype.isFixedHeightOverridden_ = false;


/**
 * @return {boolean} Whether the field should be rendered with a fixed
 *    height, or should expand to fit its contents.
 * @override
 */
goog.editor.SeamlessField.prototype.isFixedHeight = function() {
  'use strict';
  return this.isFixedHeight_;
};


/**
 * @param {boolean} newVal Explicitly set whether the field should be
 *    of a fixed-height. This overrides auto-detection.
 */
goog.editor.SeamlessField.prototype.overrideFixedHeight = function(newVal) {
  'use strict';
  this.isFixedHeight_ = newVal;
  this.isFixedHeightOverridden_ = true;
};


/**
 * Auto-detect whether the current field should have a fixed height.
 * @private
 */
goog.editor.SeamlessField.prototype.autoDetectFixedHeight_ = function() {
  'use strict';
  if (!this.isFixedHeightOverridden_) {
    var originalElement = this.getOriginalElement();
    if (originalElement) {
      this.isFixedHeight_ =
          goog.style.getComputedOverflowY(originalElement) == 'auto';
    }
  }
};


/**
 * Sizes the iframe to its body's height.
 * @private
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.SeamlessField.prototype.sizeIframeToBodyHeightGecko_ = function() {
  'use strict';
  if (this.acquireSizeIframeLockGecko_()) {
    var resized = false;
    var ifr = this.getEditableIframe();
    if (ifr) {
      var fieldHeight = this.getIframeBodyHeightGecko_();

      if (this.minHeight_) {
        fieldHeight = Math.max(fieldHeight, this.minHeight_);
      }
      if (parseInt(goog.style.getStyle(ifr, 'height'), 10) != fieldHeight) {
        ifr.style.height = fieldHeight + 'px';
        resized = true;
      }
    }
    this.releaseSizeIframeLockGecko_();
    if (resized) {
      this.dispatchEvent(goog.editor.Field.EventType.IFRAME_RESIZED);
    }
  }
};


/**
 * @return {number} The height of the editable iframe's body.
 * @private
 */
goog.editor.SeamlessField.prototype.getIframeBodyHeightGecko_ = function() {
  'use strict';
  var ifr = this.getEditableIframe();
  var body = ifr.contentDocument.body;
  var htmlElement = /** @type {!HTMLElement} */ (body.parentNode);


  // If the iframe's height is 0, then the offsetHeight/scrollHeight of the
  // HTML element in the iframe can be totally wack (i.e. too large
  // by 50-500px). Also, in standard's mode the clientHeight is 0.
  if (parseInt(goog.style.getStyle(ifr, 'height'), 10) === 0) {
    goog.style.setStyle(ifr, 'height', 1 + 'px');
  }

  var fieldHeight;
  if (goog.editor.node.isStandardsMode(body)) {
    // If in standards-mode,
    // grab the HTML element as it will contain all the field's
    // contents. The body's height, for example, will not include that of
    // floated images at the bottom in standards mode.
    // Note that this value include all scrollbars *except* for scrollbars
    // on the HTML element itself.
    fieldHeight = htmlElement.offsetHeight;
  } else {
    // In quirks-mode, the body-element always seems
    // to size to the containing window.  The html-element however,
    // sizes to the content, and can thus end up with a value smaller
    // than its child body-element if the content is shrinking.
    // We want to make the iframe shrink too when the content shrinks,
    // so rather than size the iframe to the body-element, size it to
    // the html-element.
    fieldHeight = htmlElement.scrollHeight;

    // If there is a horizontal scroll, add in the thickness of the
    // scrollbar.
    if (htmlElement.clientHeight != htmlElement.offsetHeight) {
      fieldHeight += goog.editor.SeamlessField.getScrollbarWidth_();
    }
  }

  return fieldHeight;
};


/**
 * Grabs the width of a scrollbar from the browser and caches the result.
 * @return {number} The scrollbar width in pixels.
 * @private
 */
goog.editor.SeamlessField.getScrollbarWidth_ = function() {
  'use strict';
  return goog.editor.SeamlessField.scrollbarWidth_ ||
      (goog.editor.SeamlessField.scrollbarWidth_ =
           goog.style.getScrollbarWidth());
};


/**
 * Sizes the iframe to its container div's width. The width of the div
 * is controlled by its containing context, not by its contents.
 * if it extends outside of it's contents, then it gets a horizontal scroll.
 * @private
 */
goog.editor.SeamlessField.prototype.sizeIframeToWrapperGecko_ = function() {
  'use strict';
  if (this.acquireSizeIframeLockGecko_()) {
    var ifr = this.getEditableIframe();
    var field = this.getElement();
    var resized = false;
    if (ifr && field) {
      var fieldPaddingBox;
      var widthDiv = /** @type {!HTMLElement} */ (ifr.parentNode);

      var width = widthDiv.offsetWidth;
      if (parseInt(goog.style.getStyle(ifr, 'width'), 10) != width) {
        fieldPaddingBox = goog.style.getPaddingBox(field);
        ifr.style.width = width + 'px';
        field.style.width =
            width - fieldPaddingBox.left - fieldPaddingBox.right + 'px';
        resized = true;
      }

      var height = widthDiv.offsetHeight;
      if (this.isFixedHeight() &&
          parseInt(goog.style.getStyle(ifr, 'height'), 10) != height) {
        if (!fieldPaddingBox) {
          fieldPaddingBox = goog.style.getPaddingBox(field);
        }
        ifr.style.height = height + 'px';
        field.style.height =
            height - fieldPaddingBox.top - fieldPaddingBox.bottom + 'px';
        resized = true;
      }
    }
    this.releaseSizeIframeLockGecko_();
    if (resized) {
      this.dispatchEvent(goog.editor.Field.EventType.IFRAME_RESIZED);
    }
  }
};


/**
 * Perform all the sizing immediately.
 */
goog.editor.SeamlessField.prototype.doFieldSizingGecko = function() {
  'use strict';
  // Because doFieldSizingGecko can be called after a setTimeout
  // it is possible that the field has been destroyed before this call
  // to do the sizing is executed. Check for field existence and do nothing
  // if it has already been destroyed.
  if (this.getElement()) {
    // The order of operations is important here.  Sizing the iframe to the
    // wrapper could cause the width to change, which could change the line
    // wrapping, which could change the body height.  So we need to do that
    // first, then size the iframe to fit the body height.
    this.sizeIframeToWrapperGecko_();
    if (!this.isFixedHeight()) {
      this.sizeIframeToBodyHeightGecko_();
    }
  }
};


/**
 * Acquires a lock on resizing the field iframe. This is used to ensure that
 * modifications we make while in a mutation event handler don't cause
 * infinite loops.
 * @return {boolean} False if the lock is already acquired.
 * @private
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.SeamlessField.prototype.acquireSizeIframeLockGecko_ = function() {
  'use strict';
  if (this.sizeIframeLock_) {
    return false;
  }
  return this.sizeIframeLock_ = true;
};


/**
 * Releases a lock on resizing the field iframe. This is used to ensure that
 * modifications we make while in a mutation event handler don't cause
 * infinite loops.
 * @private
 */
goog.editor.SeamlessField.prototype.releaseSizeIframeLockGecko_ = function() {
  'use strict';
  /** @suppress {strictMissingProperties} Added to tighten compiler checks */
  this.sizeIframeLock_ = false;
};


// Functions dealing with blending in with the surrounding page.


/**
 * String containing the css rules that, if applied to a document's body,
 * would style that body as if it were the original element we made editable.
 * See goog.cssom.iframe.style.getElementContext for more details.
 * @type {string}
 * @private
 */
goog.editor.SeamlessField.prototype.iframeableCss_ = '';


/**
 * Gets the css rules that should be used to style an iframe's body as if it
 * were the original element that we made editable.
 * @param {boolean=} opt_forceRegeneration Set to true to not read the cached
 * copy and instead completely regenerate the css rules.
 * @return {string} The string containing the css rules to use.
 */
goog.editor.SeamlessField.prototype.getIframeableCss = function(
    opt_forceRegeneration) {
  'use strict';
  if (!this.iframeableCss_ || opt_forceRegeneration) {
    var originalElement = this.getOriginalElement();
    if (originalElement) {
      this.iframeableCss_ = goog.cssom.iframe.style.getElementContext(
          originalElement, opt_forceRegeneration);
    }
  }
  return this.iframeableCss_;
};


/**
 * Sets the css rules that should be used inside the editable iframe.
 * Note: to clear the css cache between makeNotEditable/makeEditable,
 * call this with "" as iframeableCss.
 * TODO(user): Unify all these css setting methods + Nick's open
 * CL.  This is getting ridiculous.
 * @param {string} iframeableCss String containing the css rules to use.
 */
goog.editor.SeamlessField.prototype.setIframeableCss = function(iframeableCss) {
  'use strict';
  this.iframeableCss_ = iframeableCss;
};


/**
 * Used to ensure that CSS stylings are only installed once for none
 * iframe seamless mode.
 * TODO(user): Make it a formal part of the API that you can only
 * set one set of styles globally.
 * In seamless, non-iframe mode, all the stylings would go in the
 * same document and conflict.
 * @type {boolean}
 * @private
 */
goog.editor.SeamlessField.haveInstalledCss_ = false;


// Overridden methods.


/** @override */
goog.editor.SeamlessField.prototype.usesIframe = function() {
  'use strict';
  // TODO(user): Switch Firefox to using contentEditable
  // rather than designMode iframe once contentEditable support
  // is less buggy.
  return !goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE;
};


/** @override */
goog.editor.SeamlessField.prototype.handleChange = function() {
  'use strict';
  if (this.isEventStopped(goog.editor.Field.EventType.CHANGE)) {
    return;
  }

  goog.editor.SeamlessField.superClass_.handleChange.call(this);

  if (this.usesIframe()) {
    this.sizeIframeToBodyHeightGecko_();
  }
};


/** @override */
goog.editor.SeamlessField.prototype.dispatchBlur = function() {
  'use strict';
  if (this.isEventStopped(goog.editor.Field.EventType.BLUR)) {
    return;
  }

  goog.editor.SeamlessField.superClass_.dispatchBlur.call(this);

  // Clear the selection and restore the current range back after collapsing
  // it. The ideal solution would have been to just leave the range intact; but
  // when there are multiple fields present on the page, its important that
  // the selection isn't retained when we switch between the fields. We also
  // have to make sure that the cursor position is retained when we tab in and
  // out of a field and our approach addresses both these issues.
  // Another point to note is that we do it on a setTimeout to allow for
  // DOM modifications on blur. Otherwise, something like setLoremIpsum will
  // leave a blinking cursor in the field even though it's blurred.
  if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE &&
      !goog.editor.BrowserFeature.CLEARS_SELECTION_WHEN_FOCUS_LEAVES) {
    var win = this.getEditableDomHelper().getWindow();
    var dragging = false;
    goog.events.unlistenByKey(this.listenForDragOverEventKey_);
    this.listenForDragOverEventKey_ =
        goog.events.listenOnce(win.document.body, 'dragover', function() {
          'use strict';
          dragging = true;
        });
    goog.global.setTimeout(goog.bind(function() {
      'use strict';
      // Do not clear the selection if we're only dragging text.
      // This addresses a bug on FF1.5/linux where dragging fires a blur,
      // but clearing the selection confuses Firefox's drag-and-drop
      // implementation. For more info, see http://b/1061064
      if (!dragging) {
        if (this.editableDomHelper) {
          var rng = this.getRange();

          // If there are multiple fields on a page, we need to make sure that
          // the selection isn't retained when we switch between fields. We
          // could have collapsed the range but there is a bug in GECKO where
          // the selection stays highlighted even though its backing range is
          // collapsed (http://b/1390115). To get around this, we clear the
          // selection and restore the collapsed range back in. Restoring the
          // range is important so that the cursor stays intact when we tab out
          // and into a field (See http://b/1790301 for additional details on
          // this).
          var iframeWindow = this.editableDomHelper.getWindow();
          goog.dom.Range.clearSelection(iframeWindow);

          if (rng) {
            rng.collapse(true);
            rng.select();
          }
        }
      }
    }, this), 0);
  }
};


/** @override */
goog.editor.SeamlessField.prototype.installStyles = function() {
  'use strict';
  if (!this.usesIframe()) {
    if (!goog.editor.SeamlessField.haveInstalledCss_) {
      if (this.cssStyles.getTypedStringValue()) {
        goog.style.installSafeStyleSheet(this.cssStyles, this.getElement());
      }

      // TODO(user): this should be reset to false when the editor is quit.
      // In non-iframe mode, CSS styles should only be instaled once.
      goog.editor.SeamlessField.haveInstalledCss_ = true;
    }
  }
};


/** @override */
goog.editor.SeamlessField.prototype.makeEditableInternal = function(
    opt_iframeSrc) {
  'use strict';
  if (this.usesIframe()) {
    goog.editor.SeamlessField.superClass_.makeEditableInternal.call(
        this, opt_iframeSrc);
  } else {
    var field = this.getOriginalElement();
    if (field) {
      this.setupFieldObject(field);
      field.contentEditable = true;

      this.injectContents(field.innerHTML, field);

      this.handleFieldLoad();
    }
  }
};


/** @override */
goog.editor.SeamlessField.prototype.handleFieldLoad = function() {
  'use strict';
  if (this.usesIframe()) {
    // If the CSS inheriting code screws up (e.g. makes fonts too large) and
    // the field is sized off in goog.editor.Field.makeIframeField, then we need
    // to size it correctly, but it needs to be visible for the browser
    // to have fully rendered it. We need to put this on a timeout to give
    // the browser time to render.
    var self = this;
    goog.global.setTimeout(function() {
      'use strict';
      self.doFieldSizingGecko();
    }, 0);
  }
  goog.editor.SeamlessField.superClass_.handleFieldLoad.call(this);
};


/** @override */
goog.editor.SeamlessField.prototype.getIframeAttributes = function() {
  'use strict';
  return {'frameBorder': 0, 'style': 'padding:0;'};
};


/** @override */
goog.editor.SeamlessField.prototype.attachIframe = function(iframe) {
  'use strict';
  this.autoDetectFixedHeight_();
  var field = this.getOriginalElement();
  var dh = goog.dom.getDomHelper(field);

  // Grab the width/height values of the field before modifying any CSS
  // as some of the modifications affect its size (e.g. innerHTML='')
  // Here, we set the size of the field to fixed so there's not too much
  // jiggling when we set the innerHTML of the field.
  var oldWidth = field.style.width;
  var oldHeight = field.style.height;
  goog.style.setStyle(field, 'visibility', 'hidden');

  // If there is a floated element at the bottom of the field,
  // then it needs a clearing div at the end to cause the clientHeight
  // to contain the entire field.
  // Also, with css re-writing, the margins of the first/last
  // paragraph don't seem to get included in the clientHeight. Specifically,
  // the extra divs below force the field's clientHeight to include the
  // margins on the first and last elements contained within it.
  var startDiv = dh.createDom(
      goog.dom.TagName.DIV,
      {'style': 'height:0;clear:both', 'innerHTML': '&nbsp;'});
  var endDiv = startDiv.cloneNode(true);
  field.insertBefore(startDiv, field.firstChild);
  goog.dom.appendChild(field, endDiv);

  var contentBox = goog.style.getContentBoxSize(field);
  var width = contentBox.width;
  var height = contentBox.height;

  var html = '';
  if (this.isFixedHeight()) {
    html = '&nbsp;';

    goog.style.setStyle(field, 'position', 'relative');
    goog.style.setStyle(field, 'overflow', 'visible');

    goog.style.setStyle(iframe, 'position', 'absolute');
    goog.style.setStyle(iframe, 'top', '0');
    goog.style.setStyle(iframe, 'left', '0');
  }
  goog.style.setSize(field, width, height);

  // In strict mode, browsers put blank space at the bottom and right
  // if a field when it has an iframe child, to fill up the remaining line
  // height. So make the line height = 0.
  if (goog.editor.node.isStandardsMode(field)) {
    /** @suppress {strictMissingProperties} Added to tighten compiler checks */
    this.originalFieldLineHeight_ = field.style.lineHeight;
    goog.style.setStyle(field, 'lineHeight', '0');
  }

  goog.editor.node.replaceInnerHtml(field, html);
  // Set the initial size
  goog.style.setSize(iframe, width, height);
  goog.style.setSize(field, oldWidth, oldHeight);
  goog.style.setStyle(field, 'visibility', '');
  goog.dom.appendChild(field, iframe);

  // Only write if its not IE HTTPS in which case we're waiting for load.
  if (!this.shouldLoadAsynchronously()) {
    var doc = iframe.contentWindow.document;
    if (goog.editor.node.isStandardsMode(iframe.ownerDocument)) {
      doc.open();
      var emptyHtml = goog.html.SafeHtml.concat(
          goog.html.SafeHtml.DOCTYPE_HTML, goog.html.SafeHtml.create('html'));
      goog.dom.safe.documentWrite(doc, emptyHtml);
      doc.close();
    }
  }
};


/** @override */
goog.editor.SeamlessField.prototype.getFieldFormatInfo = function(extraStyles) {
  'use strict';
  var originalElement = this.getOriginalElement();
  if (originalElement) {
    return new goog.editor.icontent.FieldFormatInfo(
        this.id, goog.editor.node.isStandardsMode(originalElement), true,
        this.isFixedHeight(), extraStyles);
  }
  throw new Error('no field');
};


/** @override */
goog.editor.SeamlessField.prototype.writeIframeContent = function(
    iframe, innerHtml, extraStyles) {
  'use strict';
  // For seamless iframes, hide the iframe while we're laying it out to
  // prevent the flicker.
  goog.style.setStyle(iframe, 'visibility', 'hidden');
  var formatInfo = this.getFieldFormatInfo(extraStyles);
  var styleInfo = new goog.editor.icontent.FieldStyleInfo(
      this.getOriginalElement(),
      this.cssStyles.getTypedStringValue() + this.getIframeableCss());
  goog.editor.icontent.writeNormalInitialBlendedIframe(
      formatInfo, innerHtml, styleInfo, iframe);
  this.doFieldSizingGecko();
  goog.style.setStyle(iframe, 'visibility', 'visible');
};


/** @override */
goog.editor.SeamlessField.prototype.restoreDom = function() {
  'use strict';
  // TODO(user): Consider only removing the iframe if we are
  // restoring the original node.
  if (this.usesIframe()) {
    goog.dom.removeNode(this.getEditableIframe());
  }
};


/** @override */
goog.editor.SeamlessField.prototype.clearListeners = function() {
  'use strict';
  goog.events.unlistenByKey(this.listenForDragOverEventKey_);
  goog.events.unlistenByKey(this.listenForIframeLoadEventKey_);

  goog.editor.SeamlessField.base(this, 'clearListeners');
};
