chromium/third_party/google-closure-library/closure/goog/events/pastehandler.js

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

/**
 * @fileoverview Provides a 'paste' event detector that works consistently
 * across different browsers.
 *
 * IE5, IE6, IE7, Safari3.0 and FF3.0 all fire 'paste' events on textareas.
 * FF2 doesn't. This class uses 'paste' events when they are available
 * and uses heuristics to detect the 'paste' event when they are not available.
 *
 * Known issue: will not detect paste events in FF2 if you pasted exactly the
 * same existing text.
 * Known issue: Opera + Mac doesn't work properly because of the meta key. We
 * can probably fix that. TODO(goto): {@link KeyboardShortcutHandler} does not
 * work either very well with opera + mac. fix that.
 *
 * @see ../demos/pastehandler.html
 */

goog.provide('goog.events.PasteHandler');
goog.provide('goog.events.PasteHandler.EventType');
goog.provide('goog.events.PasteHandler.State');

goog.require('goog.Timer');
goog.require('goog.async.ConditionalDelay');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventTarget');
goog.require('goog.events.EventType');
goog.require('goog.events.KeyCodes');
goog.require('goog.log');



/**
 * A paste event detector. Gets an `element` as parameter and fires
 * `goog.events.PasteHandler.EventType.PASTE` events when text is
 * pasted in the `element`. Uses heuristics to detect paste events in FF2.
 * See more details of the heuristic on {@link #handleEvent_}.
 *
 * @param {Element} element The textarea element we are listening on.
 * @constructor
 * @extends {goog.events.EventTarget}
 */
goog.events.PasteHandler = function(element) {
  'use strict';
  goog.events.EventTarget.call(this);

  /**
   * The element that you want to listen for paste events on.
   * @type {Element}
   * @private
   */
  this.element_ = element;

  /**
   * The last known value of the element. Kept to check if things changed. See
   * more details on {@link #handleEvent_}.
   * @type {string}
   * @private
   */
  this.oldValue_ = this.element_.value;

  /**
   * Handler for events.
   * @type {goog.events.EventHandler<!goog.events.PasteHandler>}
   * @private
   */
  this.eventHandler_ = new goog.events.EventHandler(this);

  /**
   * The last time an event occurred on the element. Kept to check whether the
   * last event was generated by two input events or by multiple fast key events
   * that got swallowed. See more details on {@link #handleEvent_}.
   * @type {number}
   * @private
   */
  this.lastTime_ = Date.now();

  if (goog.events.PasteHandler.SUPPORTS_NATIVE_PASTE_EVENT) {
    // Most modern browsers support the paste event.
    this.eventHandler_.listen(
        element, goog.events.EventType.PASTE, this.dispatch_);
  } else {
    // But FF2 and Opera doesn't. we listen for a series of events to try to
    // find out if a paste occurred. We enumerate and cover all known ways to
    // paste text on textareas.  See more details on {@link #handleEvent_}.
    var events = [
      goog.events.EventType.KEYDOWN, goog.events.EventType.BLUR,
      goog.events.EventType.FOCUS, goog.events.EventType.MOUSEOVER, 'input'
    ];
    this.eventHandler_.listen(element, events, this.handleEvent_);
  }

  /**
   * ConditionalDelay used to poll for changes in the text element once users
   * paste text. Browsers fire paste events BEFORE the text is actually present
   * in the element.value property.
   * @type {goog.async.ConditionalDelay}
   * @private
   */
  this.delay_ =
      new goog.async.ConditionalDelay(goog.bind(this.checkUpdatedText_, this));
};
goog.inherits(goog.events.PasteHandler, goog.events.EventTarget);


/**
 * The types of events fired by this class.
 * @enum {string}
 */
goog.events.PasteHandler.EventType = {
  /**
   * Dispatched as soon as the paste event is detected, but before the pasted
   * text has been added to the text element we're listening to.
   */
  PASTE: 'paste',

  /**
   * Dispatched after detecting a change to the value of text element
   * (within 200msec of receiving the PASTE event).
   */
  AFTER_PASTE: 'after_paste'
};


/**
 * The mandatory delay we expect between two `input` events, used to
 * differentiated between non key paste events and key events.
 * @type {number}
 */
goog.events.PasteHandler.MANDATORY_MS_BETWEEN_INPUT_EVENTS_TIE_BREAKER = 400;


/**
 * Whether current UA supoprts the native "paste" event type.
 * @const {boolean}
 */
goog.events.PasteHandler.SUPPORTS_NATIVE_PASTE_EVENT = true;


/**
 * The period between each time we check whether the pasted text appears in the
 * text element or not.
 * @type {number}
 * @private
 */
goog.events.PasteHandler.PASTE_POLLING_PERIOD_MS_ = 50;


/**
 * The maximum amount of time we want to poll for changes.
 * @type {number}
 * @private
 */
goog.events.PasteHandler.PASTE_POLLING_TIMEOUT_MS_ = 200;


/**
 * The states that this class can be found, on the paste detection algorithm.
 * @enum {string}
 */
goog.events.PasteHandler.State = {
  INIT: 'init',
  FOCUSED: 'focused',
  TYPING: 'typing'
};


/**
 * The initial state of the paste detection algorithm.
 * @type {goog.events.PasteHandler.State}
 * @private
 */
goog.events.PasteHandler.prototype.state_ = goog.events.PasteHandler.State.INIT;


/**
 * The previous event that caused us to be on the current state.
 * @type {?string}
 * @private
 */
goog.events.PasteHandler.prototype.previousEvent_;


/**
 * A logger, used to help us debug the algorithm.
 * @type {goog.log.Logger}
 * @private
 */
goog.events.PasteHandler.prototype.logger_ =
    goog.log.getLogger('goog.events.PasteHandler');


/** @override */
goog.events.PasteHandler.prototype.disposeInternal = function() {
  'use strict';
  goog.events.PasteHandler.superClass_.disposeInternal.call(this);
  this.eventHandler_.dispose();
  this.eventHandler_ = null;
  this.delay_.dispose();
  this.delay_ = null;
};


/**
 * Returns the current state of the paste detection algorithm. Used mostly for
 * testing.
 * @return {goog.events.PasteHandler.State} The current state of the class.
 */
goog.events.PasteHandler.prototype.getState = function() {
  'use strict';
  return this.state_;
};


/**
 * Returns the event handler.
 * @return {goog.events.EventHandler<!goog.events.PasteHandler>} The event
 *     handler.
 * @protected
 */
goog.events.PasteHandler.prototype.getEventHandler = function() {
  'use strict';
  return this.eventHandler_;
};


/**
 * Checks whether the element.value property was updated, and if so, dispatches
 * the event that let clients know that the text is available.
 * @return {boolean} Whether the polling should stop or not, based on whether
 *     we found a text change or not.
 * @private
 */
goog.events.PasteHandler.prototype.checkUpdatedText_ = function() {
  'use strict';
  if (this.oldValue_ == this.element_.value) {
    return false;
  }
  goog.log.info(this.logger_, 'detected textchange after paste');
  this.dispatchEvent(goog.events.PasteHandler.EventType.AFTER_PASTE);
  return true;
};


/**
 * Dispatches the paste event.
 * @param {goog.events.BrowserEvent} e The underlying browser event.
 * @private
 */
goog.events.PasteHandler.prototype.dispatch_ = function(e) {
  'use strict';
  var event = new goog.events.BrowserEvent(e.getBrowserEvent());
  event.type = goog.events.PasteHandler.EventType.PASTE;
  this.dispatchEvent(event);

  // Starts polling for updates in the element.value property so we can tell
  // when do dispatch the AFTER_PASTE event. (We do an initial check after an
  // async delay of 0 msec since some browsers update the text right away and
  // our poller will always wait one period before checking).
  goog.Timer.callOnce(function() {
    'use strict';
    if (!this.checkUpdatedText_()) {
      this.delay_.start(
          goog.events.PasteHandler.PASTE_POLLING_PERIOD_MS_,
          goog.events.PasteHandler.PASTE_POLLING_TIMEOUT_MS_);
    }
  }, 0, this);
};


/**
 * The main event handler which implements a state machine.
 *
 * To handle FF2, we enumerate and cover all the known ways a user can paste:
 *
 * 1) ctrl+v, shift+insert, cmd+v
 * 2) right click -> paste
 * 3) edit menu -> paste
 * 4) drag and drop
 * 5) middle click
 *
 * (1) is easy and can be detected by listening for key events and finding out
 * which keys are pressed. (2), (3), (4) and (5) do not generate a key event,
 * so we need to listen for more than that. (2-5) all generate 'input' events,
 * but so does key events. So we need to have some sort of 'how did the input
 * event was generated' history algorithm.
 *
 * (2) is an interesting case in Opera on a Mac: since Macs does not have two
 * buttons, right clicking involves pressing the CTRL key. Even more interesting
 * is the fact that opera does NOT set the e.ctrlKey bit. Instead, it sets
 * e.keyCode = 0.
 * {@link http://www.quirksmode.org/js/keys.html}
 *
 * (1) is also an interesting case in Opera on a Mac: Opera is the only browser
 * covered by this class that can detect the cmd key (FF2 can't apparently). And
 * it fires e.keyCode = 17, which is the CTRL key code.
 * {@link http://www.quirksmode.org/js/keys.html}
 *
 * NOTE(goto, user): There is an interesting thing about (5): on Linux, (5)
 * pastes the last thing that you highlighted, not the last thing that you
 * ctrl+c'ed. This code will still generate a `PASTE` event though.
 *
 * We enumerate all the possible steps a user can take to paste text and we
 * implemented the transition between the steps in a state machine. The
 * following is the design of the state machine:
 *
 * matching paths:
 *
 * (1) happens on INIT -> FOCUSED -> TYPING -> [e.ctrlKey & e.keyCode = 'v']
 * (2-3) happens on INIT -> FOCUSED -> [input event happened]
 * (4) happens on INIT -> [mouseover && text changed]
 *
 * non matching paths:
 *
 * user is typing normally
 * INIT -> FOCUS -> TYPING -> INPUT -> INIT
 *
 * @param {goog.events.BrowserEvent} e The underlying browser event.
 * @private
 */
goog.events.PasteHandler.prototype.handleEvent_ = function(e) {
  'use strict';
  // transition between states happen at each browser event, and depend on the
  // current state, the event that led to this state, and the event input.
  switch (this.state_) {
    case goog.events.PasteHandler.State.INIT: {
      this.handleUnderInit_(e);
      break;
    }
    case goog.events.PasteHandler.State.FOCUSED: {
      this.handleUnderFocused_(e);
      break;
    }
    case goog.events.PasteHandler.State.TYPING: {
      this.handleUnderTyping_(e);
      break;
    }
    default: {
      goog.log.error(this.logger_, 'invalid ' + this.state_ + ' state');
    }
  }
  this.lastTime_ = Date.now();
  this.oldValue_ = this.element_.value;
  goog.log.info(this.logger_, e.type + ' -> ' + this.state_);
  this.previousEvent_ = e.type;
};


/**
 * `goog.events.PasteHandler.EventType.INIT` is the first initial state
 * the textarea is found. You can only leave this state by setting focus on the
 * textarea, which is how users will input text. You can also paste things using
 * drag and drop, which will not generate a `goog.events.EventType.FOCUS`
 * event, but will generate a `goog.events.EventType.MOUSEOVER`.
 *
 * For browsers that support the 'paste' event, we match it and stay on the same
 * state.
 *
 * @param {goog.events.BrowserEvent} e The underlying browser event.
 * @private
 */
goog.events.PasteHandler.prototype.handleUnderInit_ = function(e) {
  'use strict';
  switch (e.type) {
    case goog.events.EventType.BLUR: {
      this.state_ = goog.events.PasteHandler.State.INIT;
      break;
    }
    case goog.events.EventType.FOCUS: {
      this.state_ = goog.events.PasteHandler.State.FOCUSED;
      break;
    }
    case goog.events.EventType.MOUSEOVER: {
      this.state_ = goog.events.PasteHandler.State.INIT;
      if (this.element_.value != this.oldValue_) {
        goog.log.info(this.logger_, 'paste by dragdrop while on init!');
        this.dispatch_(e);
      }
      break;
    }
    default: {
      goog.log.error(
          this.logger_, 'unexpected event ' + e.type + 'during init');
    }
  }
};


/**
 * `goog.events.PasteHandler.EventType.FOCUSED` is typically the second
 * state the textarea will be, which is followed by the `INIT` state. On
 * this state, users can paste in three different ways: edit -> paste,
 * right click -> paste and drag and drop.
 *
 * The latter will generate a `goog.events.EventType.MOUSEOVER` event,
 * which we match by making sure the textarea text changed. The first two will
 * generate an 'input', which we match by making sure it was NOT generated by a
 * key event (which also generates an 'input' event).
 *
 * Unfortunately, in Firefox, if you type fast, some KEYDOWN events are
 * swallowed but an INPUT event may still happen. That means we need to
 * differentiate between two consecutive INPUT events being generated either by
 * swallowed key events OR by a valid edit -> paste -> edit -> paste action. We
 * do this by checking a minimum time between the two events. This heuristic
 * seems to work well, but it is obviously a heuristic :).
 *
 * @param {goog.events.BrowserEvent} e The underlying browser event.
 * @private
 */
goog.events.PasteHandler.prototype.handleUnderFocused_ = function(e) {
  'use strict';
  switch (e.type) {
    case 'input': {
      // there are two different events that happen in practice that involves
      // consecutive 'input' events. we use a heuristic to differentiate
      // between the one that generates a valid paste action and the one that
      // doesn't.
      // @see testTypingReallyFastDispatchesTwoInputEventsBeforeTheKEYDOWNEvent
      // and
      // @see testRightClickRightClickAlsoDispatchesTwoConsecutiveInputEvents
      // Notice that an 'input' event may be also triggered by a 'middle click'
      // paste event, which is described in
      // @see testMiddleClickWithoutFocusTriggersPasteEvent
      var minimumMilisecondsBetweenInputEvents = this.lastTime_ +
          goog.events.PasteHandler
              .MANDATORY_MS_BETWEEN_INPUT_EVENTS_TIE_BREAKER;
      if (Date.now() > minimumMilisecondsBetweenInputEvents ||
          this.previousEvent_ == goog.events.EventType.FOCUS) {
        goog.log.info(this.logger_, 'paste by textchange while focused!');
        this.dispatch_(e);
      }
      break;
    }
    case goog.events.EventType.BLUR: {
      this.state_ = goog.events.PasteHandler.State.INIT;
      break;
    }
    case goog.events.EventType.KEYDOWN: {
      goog.log.info(this.logger_, 'key down ... looking for ctrl+v');
      this.state_ = goog.events.PasteHandler.State.TYPING;
      break;
    }
    case goog.events.EventType.MOUSEOVER: {
      if (this.element_.value != this.oldValue_) {
        goog.log.info(this.logger_, 'paste by dragdrop while focused!');
        this.dispatch_(e);
      }
      break;
    }
    default: {
      goog.log.error(
          this.logger_, 'unexpected event ' + e.type + ' during focused');
    }
  }
};


/**
 * `goog.events.PasteHandler.EventType.TYPING` is the third state
 * this class can be. It exists because each KEYPRESS event will ALSO generate
 * an INPUT event (because the textarea value changes), and we need to
 * differentiate between an INPUT event generated by a key event and an INPUT
 * event generated by edit -> paste actions.
 *
 * This is the state that we match the ctrl+v pattern.
 *
 * @param {goog.events.BrowserEvent} e The underlying browser event.
 * @private
 */
goog.events.PasteHandler.prototype.handleUnderTyping_ = function(e) {
  'use strict';
  switch (e.type) {
    case 'input': {
      this.state_ = goog.events.PasteHandler.State.FOCUSED;
      break;
    }
    case goog.events.EventType.BLUR: {
      this.state_ = goog.events.PasteHandler.State.INIT;
      break;
    }
    case goog.events.EventType.KEYDOWN: {
      if (e.ctrlKey && e.keyCode == goog.events.KeyCodes.V ||
          e.shiftKey && e.keyCode == goog.events.KeyCodes.INSERT ||
          e.metaKey && e.keyCode == goog.events.KeyCodes.V) {
        goog.log.info(this.logger_, 'paste by ctrl+v while keypressed!');
        this.dispatch_(e);
      }
      break;
    }
    case goog.events.EventType.MOUSEOVER: {
      if (this.element_.value != this.oldValue_) {
        goog.log.info(this.logger_, 'paste by dragdrop while keypressed!');
        this.dispatch_(e);
      }
      break;
    }
    default: {
      goog.log.error(
          this.logger_, 'unexpected event ' + e.type + ' during keypressed');
    }
  }
};