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

/**
 * @fileoverview Base class for bubble plugins.
 */

goog.provide('goog.editor.plugins.AbstractBubblePlugin');

goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.style');
goog.require('goog.events');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventType');
goog.require('goog.events.KeyCodes');
goog.require('goog.events.actionEventWrapper');
goog.require('goog.functions');
goog.require('goog.string.Unicode');
goog.require('goog.ui.Component');
goog.require('goog.ui.editor.Bubble');
goog.require('goog.userAgent');
goog.requireType('goog.events.BrowserEvent');



/**
 * Base class for bubble plugins. This is used for to connect user behavior
 * in the editor to a goog.ui.editor.Bubble UI element that allows
 * the user to modify the properties of an element on their page (e.g. the alt
 * text of an image tag).
 *
 * Subclasses should override the abstract method getBubbleTargetFromSelection()
 * with code to determine if the current selection should activate the bubble
 * type. The other abstract method createBubbleContents() should be overriden
 * with code to create the inside markup of the bubble.  The base class creates
 * the rest of the bubble.
 *
 * @constructor
 * @extends {goog.editor.Plugin}
 */
goog.editor.plugins.AbstractBubblePlugin = function() {
  'use strict';
  goog.editor.plugins.AbstractBubblePlugin.base(this, 'constructor');

  /**
   * Place to register events the plugin listens to.
   * @type {goog.events.EventHandler<
   *     !goog.editor.plugins.AbstractBubblePlugin>}
   * @protected
   */
  this.eventRegister = new goog.events.EventHandler(this);
  this.registerDisposable(this.eventRegister);

  /**
   * Instance factory function that creates a bubble UI component.  If set to a
   * non-null value, this function will be used to create a bubble instead of
   * the global factory function.  It takes as parameters the bubble parent
   * element and the z index to draw the bubble at.
   * @type {?function(!Element, number): !goog.ui.editor.Bubble}
   * @private
   */
  this.bubbleFactory_ = null;
};
goog.inherits(goog.editor.plugins.AbstractBubblePlugin, goog.editor.Plugin);


/**
 * The css class name of option link elements.
 * @type {string}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_ =
    goog.getCssName('tr_option-link');


/**
 * The css class name of link elements.
 * @type {string}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_ =
    goog.getCssName('tr_bubble_link');


/**
 * A class name to mark elements that should be reachable by keyboard tabbing.
 * @type {string}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_ =
    goog.getCssName('tr_bubble_tabbable');


/**
 * The constant string used to separate option links.
 * @type {string}
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING =
    goog.string.Unicode.NBSP + '-' + goog.string.Unicode.NBSP;


/**
 * Default factory function for creating a bubble UI component.
 * @param {!Element} parent The parent element for the bubble.
 * @param {number} zIndex The z index to draw the bubble at.
 * @return {!goog.ui.editor.Bubble} The new bubble component.
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_ = function(
    parent, zIndex) {
  'use strict';
  return new goog.ui.editor.Bubble(parent, zIndex);
};


/**
 * Global factory function that creates a bubble UI component. It takes as
 * parameters the bubble parent element and the z index to draw the bubble at.
 * @type {function(!Element, number): !goog.ui.editor.Bubble}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ =
    goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_;


/**
 * Sets the global bubble factory function.
 * @param {function(!Element, number): !goog.ui.editor.Bubble}
 *     bubbleFactory Function that creates a bubble for the given bubble parent
 *     element and z index.
 */
goog.editor.plugins.AbstractBubblePlugin.setBubbleFactory = function(
    bubbleFactory) {
  'use strict';
  goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ = bubbleFactory;
};


/**
 * Map from field id to shared bubble object.
 * @type {!Object<goog.ui.editor.Bubble>}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.bubbleMap_ = {};


/**
 * The optional parent of the bubble.  If null or not set, we will use the
 * application document. This is useful when you have an editor embedded in
 * a scrolling DIV.
 * @type {Element|undefined}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.bubbleParent_;


/**
 * The id of the panel this plugin added to the shared bubble.  Null when
 * this plugin doesn't currently have a panel in a bubble.
 * @type {string?}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.panelId_ = null;


/**
 * Whether this bubble should support tabbing through elements. False
 * by default.
 * @type {boolean}
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.keyboardNavigationEnabled_ =
    false;


/**
 * Sets the instance bubble factory function.  If set to a non-null value, this
 * function will be used to create a bubble instead of the global factory
 * function.
 * @param {?function(!Element, number): !goog.ui.editor.Bubble} bubbleFactory
 *     Function that creates a bubble for the given bubble parent element and z
 *     index.  Null to reset the factory function.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleFactory = function(
    bubbleFactory) {
  'use strict';
  this.bubbleFactory_ = bubbleFactory;
};


/**
 * Sets whether the bubble should support tabbing through elements.
 * @param {boolean} keyboardNavigationEnabled
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.enableKeyboardNavigation =
    function(keyboardNavigationEnabled) {
  'use strict';
  this.keyboardNavigationEnabled_ = keyboardNavigationEnabled;
};


/**
 * Sets the bubble parent.
 * @param {Element} bubbleParent An element where the bubble will be
 *     anchored. If null, we will use the application document. This
 *     is useful when you have an editor embedded in a scrolling div.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleParent = function(
    bubbleParent) {
  'use strict';
  this.bubbleParent_ = bubbleParent;
};


/**
 * Returns the bubble map.  Subclasses may override to use a separate map.
 * @return {!Object<goog.ui.editor.Bubble>}
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleMap = function() {
  'use strict';
  return goog.editor.plugins.AbstractBubblePlugin.bubbleMap_;
};


/**
 * @return {goog.dom.DomHelper} The dom helper for the bubble window.
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleDom = function() {
  'use strict';
  return this.dom_;
};


/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.getTrogClassId =
    goog.functions.constant('AbstractBubblePlugin');


/**
 * Returns the element whose properties the bubble manipulates.
 * @return {Element} The target element.
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.getTargetElement =
    function() {
  'use strict';
  return this.targetElement_;
};


/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyUp = function(e) {
  'use strict';
  // For example, when an image is selected, pressing any key overwrites
  // the image and the panel should be hidden.
  // Therefore we need to track key presses when the bubble is showing.
  if (this.isVisible()) {
    this.handleSelectionChange();
  }
  return false;
};


/**
 * Pops up a property bubble for the given selection if appropriate and closes
 * open property bubbles if no longer needed.  This should not be overridden.
 * @override
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.handleSelectionChange =
    function(opt_e, opt_target) {
  'use strict';
  var selectedElement;
  if (opt_e) {
    selectedElement = /** @type {Element} */ (opt_e.target);
  } else if (opt_target) {
    selectedElement = /** @type {Element} */ (opt_target);
  } else {
    var range = this.getFieldObject().getRange();
    if (range) {
      var startNode = range.getStartNode();
      var endNode = range.getEndNode();
      var startOffset = range.getStartOffset();
      var endOffset = range.getEndOffset();
      // Sometimes in IE, the range will be collapsed, but think the end node
      // and start node are different (although in the same visible position).
      // In this case, favor the position IE thinks is the start node.
      if (goog.userAgent.IE && range.isCollapsed() && startNode != endNode) {
        range = goog.dom.Range.createCaret(startNode, startOffset);
      }
      if (startNode.nodeType == goog.dom.NodeType.ELEMENT &&
          startNode == endNode && startOffset == endOffset - 1) {
        var element = startNode.childNodes[startOffset];
        if (element.nodeType == goog.dom.NodeType.ELEMENT) {
          selectedElement = /** @type {!Element} */ (element);
        }
      }
    }
    selectedElement = selectedElement || range && range.getContainerElement();
  }
  return this.handleSelectionChangeInternal(selectedElement);
};


/**
 * Pops up a property bubble for the given selection if appropriate and closes
 * open property bubbles if no longer needed.
 * @param {Element?} selectedElement The selected element.
 * @return {boolean} Always false, allowing every bubble plugin to handle the
 *     event.
 * @protected
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.plugins.AbstractBubblePlugin.prototype
    .handleSelectionChangeInternal = function(selectedElement) {
  'use strict';
  if (selectedElement) {
    var bubbleTarget = this.getBubbleTargetFromSelection(selectedElement);
    if (bubbleTarget) {
      if (bubbleTarget != this.targetElement_ || !this.panelId_) {
        // Make sure any existing panel of the same type is closed before
        // creating a new one.
        if (this.panelId_) {
          this.closeBubble();
        }
        this.createBubble(bubbleTarget);
      }
      return false;
    }
  }

  if (this.panelId_) {
    this.closeBubble();
  }

  return false;
};


/**
 * Should be overriden by subclasses to return the bubble target element or
 * null if an element of their required type isn't found.
 * @param {Element} selectedElement The target of the selection change event or
 *     the parent container of the current entire selection.
 * @return {Element?} The HTML bubble target element or null if no element of
 *     the required type is not found.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype
    .getBubbleTargetFromSelection = goog.abstractMethod;


/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.disable = function(field) {
  'use strict';
  // When the field is made uneditable, dispose of the bubble.  We do this
  // because the next time the field is made editable again it may be in
  // a different document / iframe.
  if (field.isUneditable()) {
    var bubbleMap = this.getBubbleMap();
    var bubble = bubbleMap[field.id];
    if (bubble) {
      if (field == this.getFieldObject()) {
        this.closeBubble();
      }
      bubble.dispose();
      delete bubbleMap[field.id];
    }
  }
};


/**
 * @return {!goog.ui.editor.Bubble} The shared bubble object for the field this
 *     plugin is registered on.  Creates it if necessary.
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.getSharedBubble_ =
    function() {
  'use strict';
  var bubbleParent = /** @type {!Element} */ (
      this.bubbleParent_ || this.getFieldObject().getAppWindow().document.body);
  /** @suppress {strictMissingProperties} Added to tighten compiler checks */
  this.dom_ = goog.dom.getDomHelper(bubbleParent);

  var bubbleMap = this.getBubbleMap();
  var bubble = bubbleMap[this.getFieldObject().id];
  if (!bubble) {
    var factory = this.bubbleFactory_ ||
        goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_;
    bubble =
        factory.call(null, bubbleParent, this.getFieldObject().getBaseZindex());
    bubbleMap[this.getFieldObject().id] = bubble;
  }
  return bubble;
};


/**
 * Creates and shows the property bubble.
 * @param {Element} targetElement The target element of the bubble.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.createBubble = function(
    targetElement) {
  'use strict';
  var bubble = this.getSharedBubble_();
  if (!bubble.hasPanelOfType(this.getBubbleType())) {
    /** @suppress {strictMissingProperties} Added to tighten compiler checks */
    this.targetElement_ = targetElement;

    this.panelId_ = bubble.addPanel(
        this.getBubbleType(), this.getBubbleTitle(), targetElement,
        goog.bind(this.createBubbleContents, this),
        this.shouldPreferBubbleAboveElement());
    this.eventRegister.listen(
        bubble, goog.ui.Component.EventType.HIDE, this.handlePanelClosed_);

    this.onShow();

    if (this.keyboardNavigationEnabled_) {
      this.eventRegister.listen(
          bubble.getContentElement(), goog.events.EventType.KEYDOWN,
          this.onBubbleKey_);
    }
  }
};


/**
 * @return {string} The type of bubble shown by this plugin.  Usually the tag
 *     name of the element this bubble targets.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleType = function() {
  'use strict';
  return '';
};


/**
 * @return {string} The title for bubble shown by this plugin.  Defaults to no
 *     title.  Should be overridden by subclasses.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleTitle = function() {
  'use strict';
  return '';
};


/**
 * @return {boolean} Whether the bubble should prefer placement above the
 *     target element.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype
    .shouldPreferBubbleAboveElement = goog.functions.FALSE;


/**
 * Should be overriden by subclasses to add the type specific contents to the
 *     bubble.
 * @param {Element} bubbleContainer The container element of the bubble to
 *     which the contents should be added.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.createBubbleContents =
    goog.abstractMethod;


/**
 * Register the handler for the target's CLICK event.
 * @param {Element} target The event source element.
 * @param {Function} handler The event handler.
 * @protected
 * @deprecated Use goog.editor.plugins.AbstractBubblePlugin.
 *     registerActionHandler to register click and enter events.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.registerClickHandler =
    function(target, handler) {
  'use strict';
  this.registerActionHandler(target, handler);
};


/**
 * Register the handler for the target's CLICK and ENTER key events.
 * @param {Element} target The event source element.
 * @param {Function} handler The event handler.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.registerActionHandler =
    function(target, handler) {
  'use strict';
  this.eventRegister.listenWithWrapper(
      target, goog.events.actionEventWrapper, handler);
};


/**
 * Closes the bubble.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.closeBubble = function() {
  'use strict';
  if (this.panelId_) {
    this.getSharedBubble_().removePanel(this.panelId_);
    this.handlePanelClosed_();
  }
};


/**
 * Called after the bubble is shown. The default implementation does nothing.
 * Override it to provide your own one.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.onShow = function() {};


/**
 * Called when the bubble is closed or hidden. The default implementation does
 * nothing.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.cleanOnBubbleClose =
    function() {};


/**
 * Handles when the bubble panel is closed.  Invoked when the entire bubble is
 * hidden and also directly when the panel is closed manually.
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.handlePanelClosed_ =
    function() {
  'use strict';
  /** @suppress {strictMissingProperties} Added to tighten compiler checks */
  this.targetElement_ = null;
  this.panelId_ = null;
  this.eventRegister.removeAll();
  this.cleanOnBubbleClose();
};


/**
 * In case the keyboard navigation is enabled, this will set focus on the first
 * tabbable element in the bubble when TAB is clicked.
 * @override
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyDown = function(e) {
  'use strict';
  if (this.keyboardNavigationEnabled_ && this.isVisible() &&
      e.keyCode == goog.events.KeyCodes.TAB && !e.shiftKey) {
    var bubbleEl = this.getSharedBubble_().getContentElement();
    var tabbable = goog.dom.getElementByClass(
        goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl);
    if (tabbable) {
      tabbable.focus();
      e.preventDefault();
      return true;
    }
  }
  return false;
};


/**
 * Handles a key event on the bubble. This ensures that the focus loops through
 * the tabbable elements found in the bubble and then the focus is got by the
 * field element.
 * @param {goog.events.BrowserEvent} e The event.
 * @private
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.onBubbleKey_ = function(e) {
  'use strict';
  if (this.isVisible() && e.keyCode == goog.events.KeyCodes.TAB) {
    var bubbleEl = this.getSharedBubble_().getContentElement();
    var tabbables = goog.dom.getElementsByClass(
        goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl);
    var tabbable = e.shiftKey ? tabbables[0] : goog.array.peek(tabbables);
    var tabbingOutOfBubble = tabbable == e.target;
    if (tabbingOutOfBubble) {
      this.getFieldObject().focus();
      e.preventDefault();
    }
  }
};


/**
 * @return {boolean} Whether the bubble is visible.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.isVisible = function() {
  'use strict';
  return !!this.panelId_;
};


/**
 * Reposition the property bubble.
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.reposition = function() {
  'use strict';
  var bubble = this.getSharedBubble_();
  if (bubble) {
    bubble.reposition();
  }
};


/**
 * Helper method that creates option links (such as edit, test, remove)
 * @param {string} id String id for the span id.
 * @return {Element} The option link element.
 * @protected
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkOption = function(
    id) {
  'use strict';
  // Dash plus link are together in a span so we can hide/show them easily
  return this.dom_.createDom(
      goog.dom.TagName.SPAN, {
        id: id,
        className:
            goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_
      },
      this.dom_.createTextNode(
          goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING));
};


/**
 * Helper method that creates a link with text set to linkText and optionally
 * wires up a listener for the CLICK event or the link. The link is navigable by
 * tabs if `enableKeyboardNavigation(true)` was called.
 * @param {string} linkId The id of the link.
 * @param {string} linkText Text of the link.
 * @param {Function=} opt_onClick Optional function to call when the link is
 *     clicked.
 * @param {Element=} opt_container If specified, location to insert link. If no
 *     container is specified, the old link is removed and replaced.
 * @return {Element} The link element.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.createLink = function(
    linkId, linkText, opt_onClick, opt_container) {
  'use strict';
  var link = this.createLinkHelper(linkId, linkText, false, opt_container);
  if (opt_onClick) {
    this.registerActionHandler(link, opt_onClick);
  }
  return link;
};


/**
 * Helper method to create a link to insert into the bubble. The link is
 * navigable by tabs if `enableKeyboardNavigation(true)` was called.
 * @param {string} linkId The id of the link.
 * @param {string} linkText Text of the link.
 * @param {boolean} isAnchor Set to true to create an actual anchor tag
 *     instead of a span.  Actual links are right clickable (e.g. to open in
 *     a new window) and also update window status on hover.
 * @param {Element=} opt_container If specified, location to insert link. If no
 *     container is specified, the old link is removed and replaced.
 * @return {Element} The link element.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkHelper = function(
    linkId, linkText, isAnchor, opt_container) {
  'use strict';
  /** @suppress {strictMissingProperties} Added to tighten compiler checks */
  var link = this.dom_.createDom(
      isAnchor ? goog.dom.TagName.A : goog.dom.TagName.SPAN,
      {className: goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_},
      linkText);
  if (this.keyboardNavigationEnabled_) {
    this.setTabbable(link);
  }
  link.setAttribute('role', 'link');
  this.setupLink(link, linkId, opt_container);
  goog.editor.style.makeUnselectable(link, this.eventRegister);
  return link;
};


/**
 * Makes the given element tabbable.
 *
 * <p>Elements created by createLink[Helper] are tabbable even without
 * calling this method. Call it for other elements if needed.
 *
 * <p>If tabindex is not already set in the element, this function sets it to 0.
 * You'll usually want to also call `enableKeyboardNavigation(true)`.
 *
 * @param {!Element} element
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.setTabbable = function(
    element) {
  'use strict';
  if (!element.hasAttribute('tabindex')) {
    element.setAttribute('tabindex', 0);
  }
  goog.dom.classlist.add(
      element, goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_);
};


/**
 * Inserts a link in the given container if it is specified or removes
 * the old link with this id and replaces it with the new link
 * @param {Element} link Html element to insert.
 * @param {string} linkId Id of the link.
 * @param {Element=} opt_container If specified, location to insert link.
 * @protected
 */
goog.editor.plugins.AbstractBubblePlugin.prototype.setupLink = function(
    link, linkId, opt_container) {
  'use strict';
  if (opt_container) {
    opt_container.appendChild(/** @type {!Node} */ (link));
  } else {
    /** @suppress {strictMissingProperties} Added to tighten compiler checks */
    var oldLink = this.dom_.getElement(linkId);
    if (oldLink) {
      goog.dom.replaceNode(link, oldLink);
    }
  }

  link.id = linkId;
};
