// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";

XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm");

// Define elements that bound phone number containers.
const PHONE_NUMBER_CONTAINERS = "td,div";
const DEFER_CLOSE_TRIGGER_MS = 125; // Grace period delay before deferred _closeSelection()

// Gecko AccessibleCaret pref names.
const PREF_GECKO_ACCESSIBLECARET_ENABLED = "layout.accessiblecaret.enabled";

var SelectionHandler = {

  // Successful startSelection() or attachCaret().
  ERROR_NONE: "",

  // Error codes returned during startSelection().
  START_ERROR_INVALID_MODE: "Invalid selection mode requested.",
  START_ERROR_NONTEXT_INPUT: "Target element by definition contains no text.",
  START_ERROR_NO_WORD_SELECTED: "No word selected at point.",
  START_ERROR_SELECT_WORD_FAILED: "Word selection at point failed.",
  START_ERROR_SELECT_ALL_PARAGRAPH_FAILED: "Select-All Paragraph failed.",
  START_ERROR_NO_SELECTION: "Selection performed, but nothing resulted.",
  START_ERROR_PROXIMITY: "Selection target and result seem unrelated.",
  START_ERROR_SELECTIONCARETS_ENABLED: "Native selectionCarets requested while Gecko enabled.",

  // Error codes returned during attachCaret().
  ATTACH_ERROR_INCOMPATIBLE: "Element disabled, handled natively, or not editable.",
  ATTACH_ERROR_TOUCHCARET_ENABLED: "Native touchCaret requested while Gecko enabled.",

  HANDLE_TYPE_ANCHOR: "ANCHOR",
  HANDLE_TYPE_CARET: "CARET",
  HANDLE_TYPE_FOCUS: "FOCUS",

  TYPE_NONE: 0,
  TYPE_CURSOR: 1,
  TYPE_SELECTION: 2,

  SELECT_ALL: 0,
  SELECT_AT_POINT: 1,

  // Gecko TouchCaret/SelectionCaret pref values.
  _accessibleCaretEnabledValue: null,
  _selectionCaretEnabledValue: null,

  // Keeps track of data about the dimensions of the selection. Coordinates
  // stored here are relative to the _contentWindow window.
  _cache: { anchorPt: {}, focusPt: {} },
  _targetIsRTL: false,
  _anchorIsRTL: false,
  _focusIsRTL: false,

  _activeType: 0, // TYPE_NONE
  _selectionPrivate: null, // private selection reference
  _selectionID: null, // Unique Selection ID

  _draggingHandles: false, // True while user drags text selection handles
  _dragStartAnchorOffset: null, // Editables need initial pos during HandleMove events
  _dragStartFocusOffset: null, // Editables need initial pos during HandleMove events

  _ignoreCompositionChanges: false, // Persist caret during IME composition updates
  _prevHandlePositions: [], // Avoid issuing duplicate "TextSelection:Position" messages
  _deferCloseTimer: null, // Used to defer _closeSelection() actions during programmatic changes

  // TargetElement changes (text <--> no text) trigger actionbar UI update
  _prevTargetElementHasText: null,

  // The window that holds the selection (can be a sub-frame)
  get _contentWindow() {
    if (this._contentWindowRef)
      return this._contentWindowRef.get();
    return null;
  },

  set _contentWindow(aContentWindow) {
    this._contentWindowRef = Cu.getWeakReference(aContentWindow);
  },

  get _targetElement() {
    if (this._targetElementRef)
      return this._targetElementRef.get();
    return null;
  },

  set _targetElement(aTargetElement) {
    this._targetElementRef = Cu.getWeakReference(aTargetElement);
  },

  get _domWinUtils() {
    return BrowserApp.selectedBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).
                                                    getInterface(Ci.nsIDOMWindowUtils);
  },

  // Provides UUID service for selection ID's.
  get _idService() {
    delete this._idService;
    return this._idService = Cc["@mozilla.org/uuid-generator;1"].
      getService(Ci.nsIUUIDGenerator);
  },

  // Are we supporting Accessible-core or native-Java carets?
  get _accessibleCaretEnabled() {
    if (this._accessibleCaretEnabledValue == null) {
      try {
        this._accessibleCaretEnabledValue = Services.prefs.getBoolPref(PREF_GECKO_ACCESSIBLECARET_ENABLED);
      } catch (unused) { }
      Services.prefs.addObserver(PREF_GECKO_ACCESSIBLECARET_ENABLED, function() {
        SelectionHandler._accessibleCaretEnabledValue =
          Services.prefs.getBoolPref(PREF_GECKO_ACCESSIBLECARET_ENABLED);
      }, false);
    }
    return this._accessibleCaretEnabledValue;
  },

  _addObservers: function sh_addObservers() {
    Services.obs.addObserver(this, "Gesture:SingleTap", false);
    Services.obs.addObserver(this, "Tab:Selected", false);
    Services.obs.addObserver(this, "TextSelection:Move", false);
    Services.obs.addObserver(this, "TextSelection:Position", false);
    Services.obs.addObserver(this, "TextSelection:End", false);
    Services.obs.addObserver(this, "TextSelection:Action", false);
    Services.obs.addObserver(this, "TextSelection:LayerReflow", false);

    BrowserApp.deck.addEventListener("pagehide", this, false);
    BrowserApp.deck.addEventListener("blur", this, true);
    BrowserApp.deck.addEventListener("scroll", this, true);
  },

  _removeObservers: function sh_removeObservers() {
    Services.obs.removeObserver(this, "Gesture:SingleTap");
    Services.obs.removeObserver(this, "Tab:Selected");
    Services.obs.removeObserver(this, "TextSelection:Move");
    Services.obs.removeObserver(this, "TextSelection:Position");
    Services.obs.removeObserver(this, "TextSelection:End");
    Services.obs.removeObserver(this, "TextSelection:Action");
    Services.obs.removeObserver(this, "TextSelection:LayerReflow");

    BrowserApp.deck.removeEventListener("pagehide", this, false);
    BrowserApp.deck.removeEventListener("blur", this, true);
    BrowserApp.deck.removeEventListener("scroll", this, true);
  },

  observe: function sh_observe(aSubject, aTopic, aData) {
    // Ignore all but selectionListener notifications during deferred _closeSelection().
    if (this._deferCloseTimer) {
      return;
    }

    switch (aTopic) {
      // Update selectionListener and handle/caret positions, on page reflow
      // (keyboard open/close, dynamic DOM changes, orientation updates, etc).
      case "TextSelection:LayerReflow": {
        if (this._activeType == this.TYPE_SELECTION) {
          this._updateSelectionListener();
        }
        if (this._activeType != this.TYPE_NONE) {
          this._positionHandlesOnChange();
        }
        break;
      }

      case "Gesture:SingleTap": {
        if (this._activeType == this.TYPE_CURSOR) {
          // attachCaret() is called in the "Gesture:SingleTap" handler in BrowserEventHandler
          // We're guaranteed to call this first, because this observer was added last
          this._deactivate();
        }
        break;
      }

      case "Tab:Selected":
        this._closeSelection();
        break;

      case "TextSelection:End":
        let data = JSON.parse(aData);
        // End the requested selection only.
        if (this._selectionID === data.selectionID) {
          this._closeSelection();
        }
        break;

      case "TextSelection:Action":
        for (let type in this.actions) {
          if (this.actions[type].id == aData) {
            this.actions[type].action(this._targetElement);
            break;
          }
        }
        break;

      case "TextSelection:Move": {
        let data = JSON.parse(aData);
        if (this._activeType == this.TYPE_SELECTION) {
          this._startDraggingHandles();
          this._moveSelection(data.handleType, new Point(data.x, data.y));

        } else if (this._activeType == this.TYPE_CURSOR) {
          this._startDraggingHandles();

          // Ignore IMM composition notifications when caret movement starts
          this._ignoreCompositionChanges = true;
          this._moveCaret(data.x, data.y);

          // Move the handle directly under the caret
          this._positionHandles();
        }
        break;
      }

      case "TextSelection:Position": {
        if (this._activeType == this.TYPE_SELECTION) {
          this._startDraggingHandles();
          this._ensureSelectionDirection();
          this._stopDraggingHandles();
          this._positionHandles();

          // Changes to handle position can affect selection context and actionbar display
          this._updateMenu();

        } else if (this._activeType == this.TYPE_CURSOR) {
          // Act on IMM composition notifications after caret movement ends
          this._ignoreCompositionChanges = false;
          this._stopDraggingHandles();
          this._positionHandles();

        } else {
          Cu.reportError("Ignored \"TextSelection:Position\" message during invalid selection status");
        }

        break;
      }

      case "TextSelection:Get":
        Messaging.sendRequest({
          type: "TextSelection:Data",
          requestId: aData,
          text: this._getSelectedText()
        });
        break;
    }
  },

  // Ignore selectionChange notifications during handle dragging, disable dynamic
  // IME text compositions (autoSuggest, autoCorrect, etc)
  _startDraggingHandles: function sh_startDraggingHandles() {
    if (!this._draggingHandles) {
      this._draggingHandles = true;
      let selection = this._getSelection();
      this._dragStartAnchorOffset = selection.anchorOffset;
      this._dragStartFocusOffset = selection.focusOffset;
      Messaging.sendRequest({ type: "TextSelection:DraggingHandle", dragging: true });
    }
  },

  // Act on selectionChange notifications when not dragging handles, allow dynamic
  // IME text compositions (autoSuggest, autoCorrect, etc)
  _stopDraggingHandles: function sh_stopDraggingHandles() {
    if (this._draggingHandles) {
      this._draggingHandles = false;
      this._dragStartAnchorOffset = null;
      this._dragStartFocusOffset = null;
      Messaging.sendRequest({ type: "TextSelection:DraggingHandle", dragging: false });
    }
  },

  handleEvent: function sh_handleEvent(aEvent) {
    // Ignore all but selectionListener notifications during deferred _closeSelection().
    if (this._deferCloseTimer) {
      return;
    }

    switch (aEvent.type) {
      case "scroll":
        // Maintain position when top-level document is scrolled
        this._positionHandlesOnChange();
        break;

      case "pagehide": {
        // We only care about events on the selected tab.
        let tab = BrowserApp.getTabForWindow(aEvent.originalTarget.defaultView);
        if (tab == BrowserApp.selectedTab) {
          this._closeSelection();
        }
        break;
      }

      case "blur":
        this._closeSelection();
        break;

      // Update caret position on keyboard activity
      case "keyup":
        // Not generated by Swiftkeyboard
      case "compositionupdate":
      case "compositionend":
        // Generated by SwiftKeyboard, et. al.
        if (!this._ignoreCompositionChanges) {
          this._positionHandles();
        }
        break;
    }
  },

  /** Returns true if the provided element can be selected in text selection, false otherwise. */
  canSelect: function sh_canSelect(aElement) {
    return !(aElement instanceof Ci.nsIDOMHTMLButtonElement ||
             aElement instanceof Ci.nsIDOMHTMLEmbedElement ||
             aElement instanceof Ci.nsIDOMHTMLImageElement ||
             aElement instanceof Ci.nsIDOMHTMLMediaElement) &&
             aElement.style.MozUserSelect != 'none';
  },

  _getScrollPos: function sh_getScrollPos() {
    // Get the current display position
    let scrollX = {}, scrollY = {};
    this._contentWindow.top.QueryInterface(Ci.nsIInterfaceRequestor).
                            getInterface(Ci.nsIDOMWindowUtils).getScrollXY(false, scrollX, scrollY);
    return {
      X: scrollX.value,
      Y: scrollY.value
    };
  },

  /**
   * Add a selection listener to monitor for external selection changes.
   */
  _addSelectionListener: function(selection) {
    this._selectionPrivate = selection.QueryInterface(Ci.nsISelectionPrivate);
    this._selectionPrivate.addSelectionListener(this);
  },

  /**
   * The nsISelection object for an editable can change during DOM mutations,
   * causing us to stop receiving selectionChange notifications.
   *
   * We can detect that after a layer-reflow event, and dynamically update the
   * listener.
   */
  _updateSelectionListener: function() {
    if (!(this._targetElement instanceof Ci.nsIDOMNSEditableElement)) {
      return;
    }

    let selection = this._getSelection();
    if (this._selectionPrivate != selection.QueryInterface(Ci.nsISelectionPrivate)) {
      this._removeSelectionListener();
      this._addSelectionListener(selection);
    }
  },

  /**
   * Remove the selection listener.
   */
  _removeSelectionListener: function() {
    this._selectionPrivate.removeSelectionListener(this);
    this._selectionPrivate = null;
  },

  /**
   * Observe and react to programmatic SelectionChange notifications.
   */
  notifySelectionChanged: function sh_notifySelectionChanged(aDocument, aSelection, aReason) {
    // Cancel any in-progress / deferred _closeSelection() action.
    this._cancelDeferredCloseSelection();

    // Ignore selectionChange notifications during handle movements
    if (this._draggingHandles) {
      return;
    }

    // If the selection was collapsed to Start or to End, always close it
    if ((aReason & Ci.nsISelectionListener.COLLAPSETOSTART_REASON) ||
        (aReason & Ci.nsISelectionListener.COLLAPSETOEND_REASON)) {
      this._closeSelection();
      return;
    }

    // If selected text no longer exists, schedule a deferred close action.
    if (!aSelection.toString()) {
      this._deferCloseSelection();
      return;
    }

    // Update the selection handle positions.
    this._positionHandles();
  },

  /*
   * Called from browser.js when the user long taps on text or chooses
   * the "Select Word" context menu item. Initializes SelectionHandler,
   * starts a selection, and positions the text selection handles.
   *
   * @param aOptions list of options describing how to start selection
   *                 Options include:
   *                   mode - SELECT_ALL to select everything in the target
   *                          element, or SELECT_AT_POINT to select a word.
   *                   x    - The x-coordinate for SELECT_AT_POINT.
   *                   y    - The y-coordinate for SELECT_AT_POINT.
   */
  startSelection: function sh_startSelection(aElement, aOptions = { mode: SelectionHandler.SELECT_ALL }) {
    // Disable Native touchCarets if Gecko AccessibleCaret enabled.
    if (this._accessibleCaretEnabled) {
      return this.START_ERROR_SELECTIONCARETS_ENABLED;
    }

    // Clear out any existing active selection
    this._closeSelection();

    if (this._isNonTextInputElement(aElement)) {
      return this.START_ERROR_NONTEXT_INPUT;
    }

    const focus = Services.focus.focusedWindow;
    if (focus) {
      // Make sure any previous focus is cleared.
      Services.focus.clearFocus(focus);
    }

    this._initTargetInfo(aElement, this.TYPE_SELECTION);

    // Perform the appropriate selection method, if we can't determine method, or it fails, return
    let selectionResult = this._performSelection(aOptions);
    if (selectionResult !== this.ERROR_NONE) {
      this._deactivate();
      return selectionResult;
    }

    // Double check results of successful selection operation
    let selection = this._getSelection();
    if (!selection ||
        selection.rangeCount == 0 ||
        selection.getRangeAt(0).collapsed ||
        this._getSelectedText().length == 0) {
      this._deactivate();
      return this.START_ERROR_NO_SELECTION;
    }

    // Add a listener to end the selection if it's removed programatically
    this._addSelectionListener(selection);
    this._activeType = this.TYPE_SELECTION;

    // Figure out the distance between the selection and the click
    let scroll = this._getScrollPos();
    let positions = this._getHandlePositions(scroll);

    if (aOptions.mode == this.SELECT_AT_POINT &&
        !this._selectionNearClick(scroll.X + aOptions.x, scroll.Y + aOptions.y, positions)) {
        this._closeSelection();
        return this.START_ERROR_PROXIMITY;
    }

    // Determine position and show handles, open actionbar
    this._positionHandles(positions);
    Messaging.sendRequest({
      selectionID: this._selectionID,
      type: "TextSelection:ShowHandles",
      handles: [this.HANDLE_TYPE_ANCHOR, this.HANDLE_TYPE_FOCUS]
    });
    this._updateMenu();
    return this.ERROR_NONE;
  },

  /*
   * Called to perform a selection operation, given a target element, selection method, starting point etc.
   */
  _performSelection: function sh_performSelection(aOptions) {
    if (aOptions.mode == this.SELECT_AT_POINT) {
      // Clear any ranges selected outside SelectionHandler, by code such as Find-In-Page.
      this._contentWindow.getSelection().removeAllRanges();
      try {
        if (!this._domWinUtils.selectAtPoint(aOptions.x, aOptions.y, Ci.nsIDOMWindowUtils.SELECT_WORDNOSPACE)) {
          return this.START_ERROR_NO_WORD_SELECTED;
        }
      } catch (e) {
        return this.START_ERROR_SELECT_WORD_FAILED;
      }

      // Perform additional phone-number "smart selection".
      if (this._isPhoneNumber(this._getSelection().toString())) {
        this._selectSmartPhoneNumber();
      }

      return this.ERROR_NONE;
    }

    // Only selectAll() assumed from this point.
    if (aOptions.mode != this.SELECT_ALL) {
      return this.START_ERROR_INVALID_MODE;
    }

    // HTMLPreElement is a #text node, SELECT_ALL implies entire paragraph
    if (this._targetElement instanceof HTMLPreElement)  {
      try {
        this._domWinUtils.selectAtPoint(1, 1, Ci.nsIDOMWindowUtils.SELECT_PARAGRAPH);
        return this.ERROR_NONE;
      } catch (e) {
        return this.START_ERROR_SELECT_ALL_PARAGRAPH_FAILED;
      }
    }

    // Else default to selectALL Document
    let editor = this._getEditor();
    if (editor) {
      editor.selectAll();
    } else {
      this._getSelectionController().selectAll();
    }

    // Selection is entire HTMLHtmlElement, remove any trailing document whitespace
    let selection = this._getSelection();
    let lastNode = selection.focusNode;
    while (lastNode && lastNode.lastChild) {
      lastNode = lastNode.lastChild;
    }

    if (lastNode instanceof Text) {
      try {
        selection.extend(lastNode, lastNode.length);
      } catch (e) {
        Cu.reportError("SelectionHandler.js: _performSelection() whitespace trim fails: lastNode[" + lastNode +
          "] lastNode.length[" + lastNode.length + "]");
      }
    }

    return this.ERROR_NONE;
  },

  /*
   * Called to expand a selection that appears to represent a phone number. This enhances the basic
   * SELECT_WORDNOSPACE logic employed in performSelection() in response to long-tap / selecting text.
   */
  _selectSmartPhoneNumber: function() {
    this._extendPhoneNumberSelection("forward");
    this._reversePhoneNumberSelectionDir();

    this._extendPhoneNumberSelection("backward");
    this._reversePhoneNumberSelectionDir();
  },

  /*
   * Extend the current phone number selection in the requested direction.
   */
  _extendPhoneNumberSelection: function(direction) {
    let selection = this._getSelection();

    // Extend the phone number selection until we find a boundry.
    while (true) {
      // Save current focus position, and extend the selection.
      let focusNode = selection.focusNode;
      let focusOffset = selection.focusOffset;
      selection.modify("extend", direction, "character");

      // If the selection doesn't change, (can't extend further), we're done.
      if (selection.focusNode == focusNode && selection.focusOffset == focusOffset) {
        return;
      }

      // Don't extend past a valid phone number.
      if (!this._isPhoneNumber(selection.toString().trim())) {
        // Backout the undesired selection extend, and we're done.
        selection.collapse(selection.anchorNode, selection.anchorOffset);
        selection.extend(focusNode, focusOffset);
        return;
      }

      // Don't extend the selection into a new container.
      if (selection.focusNode != focusNode) {
        let nextContainer = (selection.focusNode instanceof Text) ?
          selection.focusNode.parentNode : selection.focusNode;
        if (nextContainer.matches &&
            nextContainer.matches(PHONE_NUMBER_CONTAINERS)) {
          // Backout the undesired selection extend, and we're done.
          selection.collapse(selection.anchorNode, selection.anchorOffset);
          selection.extend(focusNode, focusOffset);
          return
        }
      }
    }
  },

  /*
   * Reverse the the selection direction, swapping anchorNode <-+-> focusNode.
   */
  _reversePhoneNumberSelectionDir: function(direction) {
    let selection = this._getSelection();

    let anchorNode = selection.anchorNode;
    let anchorOffset = selection.anchorOffset;
    selection.collapse(selection.focusNode, selection.focusOffset);
    selection.extend(anchorNode, anchorOffset);
  },

  /* Return true if the current selection (given by aPositions) is near to where the coordinates passed in */
  _selectionNearClick: function(aX, aY, aPositions) {
      let distance = 0;

      // Check if the click was in the bounding box of the selection handles
      if (aPositions[0].left < aX && aX < aPositions[1].left
          && aPositions[0].top < aY && aY < aPositions[1].top) {
        distance = 0;
      } else {
        // If it was outside, check the distance to the center of the selection
        let selectposX = (aPositions[0].left + aPositions[1].left) / 2;
        let selectposY = (aPositions[0].top + aPositions[1].top) / 2;

        let dx = Math.abs(selectposX - aX);
        let dy = Math.abs(selectposY - aY);
        distance = dx + dy;
      }

      let maxSelectionDistance = Services.prefs.getIntPref("browser.ui.selection.distance");
      return (distance < maxSelectionDistance);
  },

  /* Reads a value from an action. If the action defines the value as a function, will return the result of calling
     the function. Otherwise, will return the value itself. If the value isn't defined for this action, will return a default */
  _getValue: function(obj, name, defaultValue) {
    if (!(name in obj))
      return defaultValue;

    if (typeof obj[name] == "function")
      return obj[name](this._targetElement);

    return obj[name];
  },

  addAction: function(action) {
    if (!action.id)
      action.id = uuidgen.generateUUID().toString()

    if (this.actions[action.id])
      throw "Action with id " + action.id + " already added";

    // Update actions list and actionbar UI if active.
    this.actions[action.id] = action;
    this._updateMenu();
    return action.id;
  },

  removeAction: function(id) {
    // Update actions list and actionbar UI if active.
    delete this.actions[id];
    this._updateMenu();
  },

  _updateMenu: function() {
    if (this._activeType == this.TYPE_NONE) {
      return;
    }

    // Update actionbar UI.
    let actions = [];
    for (let type in this.actions) {
      let action = this.actions[type];
      if (action.selector.matches(this._targetElement)) {
        let a = {
          id: action.id,
          label: this._getValue(action, "label", ""),
          icon: this._getValue(action, "icon", "drawable://ic_status_logo"),
          showAsAction: this._getValue(action, "showAsAction", true),
          order: this._getValue(action, "order", 0)
        };
        actions.push(a);
      }
    }

    actions.sort((a, b) => b.order - a.order);

    Messaging.sendRequest({
      type: "TextSelection:Update",
      actions: actions
    });
  },

  /*
   * Actionbar methods.
   */
  actions: {
    SELECT_ALL: {
      label: Strings.browser.GetStringFromName("contextmenu.selectAll"),
      id: "selectall_action",
      icon: "drawable://ab_select_all",
      action: function(aElement) {
        SelectionHandler.startSelection(aElement);
        UITelemetry.addEvent("action.1", "actionbar", null, "select_all");
      },
      order: 5,
      selector: {
        matches: function(aElement) {
          return (aElement.textLength != 0);
        }
      }
    },

    CUT: {
      label: Strings.browser.GetStringFromName("contextmenu.cut"),
      id: "cut_action",
      icon: "drawable://ab_cut",
      action: function(aElement) {
        let start = aElement.selectionStart;
        let end   = aElement.selectionEnd;

        SelectionHandler.copySelection();
        aElement.value = aElement.value.substring(0, start) + aElement.value.substring(end)

        // copySelection closes the selection. Show a caret where we just cut the text.
        SelectionHandler.attachCaret(aElement);
        UITelemetry.addEvent("action.1", "actionbar", null, "cut");
      },
      order: 4,
      selector: {
        matches: function(aElement) {
          // Disallow cut for contentEditable elements (until Bug 1112276 is fixed).
          return !aElement.isContentEditable && SelectionHandler.isElementEditableText(aElement) ?
            SelectionHandler.isSelectionActive() : false;
        }
      }
    },

    COPY: {
      label: Strings.browser.GetStringFromName("contextmenu.copy"),
      id: "copy_action",
      icon: "drawable://ab_copy",
      action: function() {
        SelectionHandler.copySelection();
        UITelemetry.addEvent("action.1", "actionbar", null, "copy");
      },
      order: 3,
      selector: {
        matches: function(aElement) {
          // Don't include "copy" for password fields.
          // mozIsTextField(true) tests for only non-password fields.
          if (aElement instanceof Ci.nsIDOMHTMLInputElement && !aElement.mozIsTextField(true)) {
            return false;
          }
          return SelectionHandler.isSelectionActive();
        }
      }
    },

    PASTE: {
      label: Strings.browser.GetStringFromName("contextmenu.paste"),
      id: "paste_action",
      icon: "drawable://ab_paste",
      action: function(aElement) {
        if (aElement) {
          let target = SelectionHandler._getEditor();
          aElement.focus();
          target.paste(Ci.nsIClipboard.kGlobalClipboard);
          SelectionHandler._closeSelection();
          UITelemetry.addEvent("action.1", "actionbar", null, "paste");
        }
      },
      order: 2,
      selector: {
        matches: function(aElement) {
          if (SelectionHandler.isElementEditableText(aElement)) {
            let flavors = ["text/unicode"];
            return Services.clipboard.hasDataMatchingFlavors(flavors, flavors.length, Ci.nsIClipboard.kGlobalClipboard);
          }
          return false;
        }
      }
    },

    SHARE: {
      label: Strings.browser.GetStringFromName("contextmenu.share"),
      id: "share_action",
      icon: "drawable://ic_menu_share",
      action: function() {
        SelectionHandler.shareSelection();
        UITelemetry.addEvent("action.1", "actionbar", null, "share");
      },
      selector: {
        matches: function() {
          if (!ParentalControls.isAllowed(ParentalControls.SHARE)) {
            return false;
          }

          return SelectionHandler.isSelectionActive();
        }
      }
    },

    SEARCH_ADD: {
      id: "search_add_action",
      label: Strings.browser.GetStringFromName("contextmenu.addSearchEngine2"),
      icon: "drawable://ab_add_search_engine",

      selector: {
        matches: function(element) {
          if(!(element instanceof HTMLInputElement)) {
            return false;
          }
          let form = element.form;
          if (!form || element.type == "password") {
            return false;
          }

          // These are the following types of forms we can create keywords for:
          //
          // method    encoding type        can create keyword
          // GET       *                                   YES
          //           *                                   YES
          // POST      *                                   YES
          // POST      application/x-www-form-urlencoded   YES
          // POST      text/plain                          NO ( a little tricky to do)
          // POST      multipart/form-data                 NO
          // POST      everything else                     YES
          let method = form.method.toUpperCase();
          return (method == "GET" || method == "") ||
                 (form.enctype != "text/plain") && (form.enctype != "multipart/form-data");
        },
      },

      action: function(element) {
        UITelemetry.addEvent("action.1", "actionbar", null, "add_search_engine");
        SearchEngines.addEngine(element);
      },
    },

    SEARCH: {
      label: function() {
        return Strings.browser.formatStringFromName("contextmenu.search", [Services.search.defaultEngine.name], 1);
      },
      id: "search_action",
      icon: "drawable://ab_search",
      action: function() {
        SelectionHandler.searchSelection();
        SelectionHandler._closeSelection();
        UITelemetry.addEvent("action.1", "actionbar", null, "search");
      },
      order: 1,
      selector: {
        matches: function() {
          return SelectionHandler.isSelectionActive();
        }
      }
    },

    CALL: {
      label: Strings.browser.GetStringFromName("contextmenu.call"),
      id: "call_action",
      icon: "drawable://phone",
      action: function() {
        SelectionHandler.callSelection();
        UITelemetry.addEvent("action.1", "actionbar", null, "call");
      },
      order: 1,
      selector: {
        matches: function () {
          return SelectionHandler._getSelectedPhoneNumber() != null;
        }
      }
    }
  },

  /*
   * Called by BrowserEventHandler when the user taps in a form input.
   * Initializes SelectionHandler and positions the caret handle.
   *
   * @param aX, aY tap location in client coordinates.
   */
  attachCaret: function sh_attachCaret(aElement) {
    // Disable Native touchCarets if Gecko AccessibleCaret enabled.
    if (this._accessibleCaretEnabled) {
      return this.ATTACH_ERROR_TOUCHCARET_ENABLED;
    }

    // Clear out any existing active selection
    this._closeSelection();

    // Ensure it isn't disabled, isn't handled by Android native dialog, and is editable text element
    if (aElement.disabled || InputWidgetHelper.hasInputWidget(aElement) || !this.isElementEditableText(aElement)) {
      return this.ATTACH_ERROR_INCOMPATIBLE;
    }

    this._initTargetInfo(aElement, this.TYPE_CURSOR);

    // Caret-specific observer/listeners
    BrowserApp.deck.addEventListener("keyup", this, false);
    BrowserApp.deck.addEventListener("compositionupdate", this, false);
    BrowserApp.deck.addEventListener("compositionend", this, false);

    this._activeType = this.TYPE_CURSOR;

    // Determine position and show caret, open actionbar
    this._positionHandles();
    Messaging.sendRequest({
      selectionID: this._selectionID,
      type: "TextSelection:ShowHandles",
      handles: [this.HANDLE_TYPE_CARET]
    });
    this._updateMenu();

    return this.ERROR_NONE;
  },

  // Target initialization for both TYPE_CURSOR and TYPE_SELECTION
  _initTargetInfo: function sh_initTargetInfo(aElement, aSelectionType) {
    this._targetElement = aElement;
    if (aElement instanceof Ci.nsIDOMNSEditableElement) {
      if (aSelectionType === this.TYPE_SELECTION) {
        // Blur the targetElement to force IME code to undo previous style compositions
        // (visible underlines / etc generated by autoCorrection, autoSuggestion)
        aElement.blur();
      }
      // Ensure targetElement is now focused normally
      aElement.focus();
    }

    this._selectionID = this._idService.generateUUID().toString();
    this._stopDraggingHandles();
    this._contentWindow = aElement.ownerDocument.defaultView;
    this._targetIsRTL = (this._contentWindow.getComputedStyle(aElement, "").direction == "rtl");

    this._addObservers();
  },

  _getSelection: function sh_getSelection() {
    if (this._targetElement instanceof Ci.nsIDOMNSEditableElement)
      return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selection;
    else
      return this._contentWindow.getSelection();
  },

  _getSelectedText: function sh_getSelectedText() {
    if (!this._contentWindow)
      return "";

    let selection = this._getSelection();
    if (!selection)
      return "";

    if (this._targetElement instanceof Ci.nsIDOMHTMLTextAreaElement) {
      return selection.QueryInterface(Ci.nsISelectionPrivate).
        toStringWithFormat("text/plain", Ci.nsIDocumentEncoder.OutputPreformatted | Ci.nsIDocumentEncoder.OutputRaw, 0);
    }

    return selection.toString().trim();
  },

  _getEditor: function sh_getEditor() {
    if (this._targetElement instanceof Ci.nsIDOMNSEditableElement) {
      return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor;
    }
    return this._contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIWebNavigation)
                              .QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIEditingSession)
                              .getEditorForWindow(this._contentWindow);
  },

  _getSelectionController: function sh_getSelectionController() {
    if (this._targetElement instanceof Ci.nsIDOMNSEditableElement)
      return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selectionController;
    else
      return this._contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).
                                 getInterface(Ci.nsIWebNavigation).
                                 QueryInterface(Ci.nsIInterfaceRequestor).
                                 getInterface(Ci.nsISelectionDisplay).
                                 QueryInterface(Ci.nsISelectionController);
  },

  // Used by the contextmenu "matches" functions in ClipboardHelper
  isSelectionActive: function sh_isSelectionActive() {
    return (this._activeType == this.TYPE_SELECTION);
  },

  isElementEditableText: function (aElement) {
    return (((aElement instanceof HTMLInputElement && aElement.mozIsTextField(false)) ||
            (aElement instanceof HTMLTextAreaElement)) && !aElement.readOnly) ||
            aElement.isContentEditable;
  },

  _isNonTextInputElement: function(aElement) {
    return (aElement instanceof HTMLInputElement && !aElement.mozIsTextField(false));
  },

  /*
   * Moves the selection as the user drags a handle.
   * @param handleType: Specifies either the anchor or the focus handle.
   * @param handlePt: selection point in client coordinates.
   */
  _moveSelection: function sh_moveSelection(handleType, handlePt) {
    let isAnchorHandle = (handleType == this.HANDLE_TYPE_ANCHOR);

    // Determine new caret position from handlePt, exit if user
    // moved it offscreen.
    let viewOffset = this._getViewOffset();
    let ptX = handlePt.x - viewOffset.x;
    let ptY = handlePt.y - viewOffset.y;
    let cwd = this._contentWindow.document;
    let caretPos = cwd.caretPositionFromPoint(ptX, ptY);
    if (!caretPos) {
      return;
    }

    // Constrain text selection within editable elements.
    let targetIsEditable = this._targetElement instanceof Ci.nsIDOMNSEditableElement;
    if (targetIsEditable && (caretPos.offsetNode != this._targetElement)) {
      return;
    }

    // Update the Selection for editable elements. Selection Change
    // logic is the same, regardless of RTL/LTR. Selection direction is
    // maintained always forward (startOffset <= endOffset).
    if (targetIsEditable) {
      let start = this._dragStartAnchorOffset;
      let end = this._dragStartFocusOffset;
      if (isAnchorHandle) {
        start = caretPos.offset;
      } else {
        end = caretPos.offset;
      }
      if (start > end) {
        [start, end] = [end, start];
      }
      this._targetElement.setSelectionRange(start, end);
      return;
    }

    // Update the Selection for non-editable elements. Selection Change
    // logic is the same, regardless of RTL/LTR. Selection direction internally
    // can finish reversed by user drag. ie: Forward is (a,o ---> f,o),
    // and reversed is (a,o <--- f,o).
    let selection = this._getSelection();
    if (isAnchorHandle) {
      let focusNode = selection.focusNode;
      let focusOffset = selection.focusOffset;
      selection.collapse(caretPos.offsetNode, caretPos.offset);
      selection.extend(focusNode, focusOffset);
    } else {
      selection.extend(caretPos.offsetNode, caretPos.offset);
    }
  },

  _moveCaret: function sh_moveCaret(aX, aY) {
    // Get rect of text inside element
    let range = document.createRange();
    range.selectNodeContents(this._getEditor().rootElement);
    let textBounds = range.getBoundingClientRect();

    // Get rect of editor
    let editorBounds = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_EDITOR_RECT, 0, 0, 0, 0,
                                                               this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
    // the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so
    // divide by the pixel ratio
    let editorRect = new Rect(editorBounds.left / window.devicePixelRatio,
                              editorBounds.top / window.devicePixelRatio,
                              editorBounds.width / window.devicePixelRatio,
                              editorBounds.height / window.devicePixelRatio);

    // Use intersection of the text rect and the editor rect
    let rect = new Rect(textBounds.left, textBounds.top, textBounds.width, textBounds.height);
    rect.restrictTo(editorRect);

    // Clamp vertically and scroll if handle is at bounds. The top and bottom
    // must be restricted by an additional pixel since clicking on the top
    // edge of an input field moves the cursor to the beginning of that
    // field's text (and clicking the bottom moves the cursor to the end).
    if (aY < rect.y + 1) {
      aY = rect.y + 1;
      this._getSelectionController().scrollLine(false);
    } else if (aY > rect.y + rect.height - 1) {
      aY = rect.y + rect.height - 1;
      this._getSelectionController().scrollLine(true);
    }

    // Clamp horizontally and scroll if handle is at bounds
    if (aX < rect.x) {
      aX = rect.x;
      this._getSelectionController().scrollCharacter(false);
    } else if (aX > rect.x + rect.width) {
      aX = rect.x + rect.width;
      this._getSelectionController().scrollCharacter(true);
    }

    this._domWinUtils.sendMouseEventToWindow("mousedown", aX, aY, 0, 0, 0, true);
    this._domWinUtils.sendMouseEventToWindow("mouseup", aX, aY, 0, 0, 0, true);
  },

  copySelection: function sh_copySelection() {
    let selectedText = this._getSelectedText();
    if (selectedText.length) {
      let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
      clipboard.copyString(selectedText);
      Snackbars.show(Strings.browser.GetStringFromName("selectionHelper.textCopied"), Snackbars.LENGTH_LONG);
    }
    this._closeSelection();
  },

  shareSelection: function sh_shareSelection() {
    let selectedText = this._getSelectedText();
    if (selectedText.length) {
      Messaging.sendRequest({
        type: "Share:Text",
        text: selectedText
      });
    }
    this._closeSelection();
  },

  searchSelection: function sh_searchSelection() {
    let selectedText = this._getSelectedText();
    if (selectedText.length) {
      let req = Services.search.defaultEngine.getSubmission(selectedText);
      let parent = BrowserApp.selectedTab;
      let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser);
      // Set current tab as parent of new tab, and set new tab as private if the parent is.
      BrowserApp.addTab(req.uri.spec, {parentId: parent.id,
                                       selected: true,
                                       isPrivate: isPrivate});
    }
    this._closeSelection();
  },

  _phoneRegex: /^\+?[0-9\s,-.\(\)*#pw]{1,30}$/,

  _getSelectedPhoneNumber: function sh_getSelectedPhoneNumber() {
    return this._isPhoneNumber(this._getSelectedText().trim());
  },

  _isPhoneNumber: function sh_isPhoneNumber(selectedText) {
    return (this._phoneRegex.test(selectedText) ? selectedText : null);
  },

  callSelection: function sh_callSelection() {
    let selectedText = this._getSelectedPhoneNumber();
    if (selectedText) {
      BrowserApp.loadURI("tel:" + selectedText);
    }
    this._closeSelection();
  },

  /**
   * Deferred _closeSelection() actions allow for brief periods where programmatic
   * selection changes have effectively closed the selection, but we anticipate further
   * activity that may restore it.
   *
   * At this point, we hide the UI handles, and stop responding to messages until
   * either the final _closeSelection() is triggered, or until our Gecko selectionListener
   * notices a subsequent programmatic selection that results in a new selection.
   */
  _deferCloseSelection: function() {
    // Schedule the deferred _closeSelection() action.
    this._deferCloseTimer = setTimeout((function() {
      // Time is up! Close the selection.
      this._deferCloseTimer = null;
      this._closeSelection();
    }).bind(this), DEFER_CLOSE_TRIGGER_MS);

    // Hide any handles while deferClosed.
    if (this._prevHandlePositions.length) {
      let positions = this._prevHandlePositions;
      for (let i in positions) {
        positions[i].hidden = true;
      }

      Messaging.sendRequest({
        type: "TextSelection:PositionHandles",
        positions: positions,
      });
    }
  },

  /**
   * Cancel any current deferred _closeSelection() action.
   */
  _cancelDeferredCloseSelection: function() {
    if (this._deferCloseTimer) {
      clearTimeout(this._deferCloseTimer);
      this._deferCloseTimer = null;
    }
  },

  /*
   * Shuts SelectionHandler down.
   */
  _closeSelection: function sh_closeSelection() {
    // Bail if there's no active selection
    if (this._activeType == this.TYPE_NONE)
      return;

    if (this._activeType == this.TYPE_SELECTION)
      this._clearSelection();

    this._deactivate();
  },

  _clearSelection: function sh_clearSelection() {
    // Cancel any in-progress / deferred _closeSelection() process.
    this._cancelDeferredCloseSelection();

    let selection = this._getSelection();
    if (selection) {
      // Remove our listener before we clear the selection
      this._removeSelectionListener();

      // Remove the selection. For editables, we clear selection without losing
      // element focus. For non-editables, just clear all.
      if (selection.rangeCount != 0) {
        if (this.isElementEditableText(this._targetElement)) {
          selection.collapseToStart();
        } else {
          selection.removeAllRanges();
        }
      }
    }
  },

  _deactivate: function sh_deactivate() {
    this._stopDraggingHandles();
    // Hide handle/caret, close actionbar
    Messaging.sendRequest({ type: "TextSelection:HideHandles" });

    this._removeObservers();

    // Only observed for caret positioning
    if (this._activeType == this.TYPE_CURSOR) {
      BrowserApp.deck.removeEventListener("keyup", this);
      BrowserApp.deck.removeEventListener("compositionupdate", this);
      BrowserApp.deck.removeEventListener("compositionend", this);
    }

    this._contentWindow = null;
    this._targetElement = null;
    this._targetIsRTL = false;
    this._ignoreCompositionChanges = false;
    this._prevHandlePositions = [];
    this._prevTargetElementHasText = null;

    this._activeType = this.TYPE_NONE;
  },

  _getViewOffset: function sh_getViewOffset() {
    let offset = { x: 0, y: 0 };
    let win = this._contentWindow;

    // Recursively look through frames to compute the total position offset.
    while (win.frameElement) {
      let rect = win.frameElement.getBoundingClientRect();
      offset.x += rect.left;
      offset.y += rect.top;

      win = win.parent;
    }

    return offset;
  },

  /*
   * The direction of the Selection is ensured for editables while the user drags
   * the handles (per "TextSelection:Move" event). For non-editables, we just let
   * the user change direction, but fix it up at the end of handle movement (final
   * "TextSelection:Position" event).
   */
  _ensureSelectionDirection: function() {
    // Never needed at this time.
    if (this._targetElement instanceof Ci.nsIDOMNSEditableElement) {
      return;
    }

    // Nothing needed if not reversed.
    let qcEventResult = this._domWinUtils.sendQueryContentEvent(
      this._domWinUtils.QUERY_SELECTED_TEXT, 0, 0, 0, 0);
    if (!qcEventResult.reversed) {
      return;
    }

    // Reverse the Selection.
    let selection = this._getSelection();
    let newFocusNode = selection.anchorNode;
    let newFocusOffset = selection.anchorOffset;

    selection.collapse(selection.focusNode, selection.focusOffset);
    selection.extend(newFocusNode, newFocusOffset);
  },

  /*
   * Updates the TYPE_SELECTION cache, with the handle anchor/focus point values
   * of the current selection. Passed to Java for UI positioning only.
   *
   * Note that the anchor handle and focus handle can reference text in nodes
   * with mixed direction. (ie a.direction = "rtl" while f.direction = "ltr").
   */
  _updateCacheForSelection: function() {
    let selection = this._getSelection();
    let rects = selection.getRangeAt(0).getClientRects();
    if (rects.length == 0) {
      // nsISelection object exists, but there's nothing actually selected
      throw "Failed to update cache for invalid selection";
    }

    // Right-to-Left (ie: Hebrew) anchorPt is on right,
    // Left-to-Right (ie: English) anchorPt is on left.
    this._anchorIsRTL = this._isNodeRTL(selection.anchorNode);
    let anchorIdx = 0;
    this._cache.anchorPt = (this._anchorIsRTL) ?
      new Point(rects[anchorIdx].right, rects[anchorIdx].bottom) :
      new Point(rects[anchorIdx].left, rects[anchorIdx].bottom);

    // Right-to-Left (ie: Hebrew) focusPt is on left,
    // Left-to-Right (ie: English) focusPt is on right.
    this._focusIsRTL = this._isNodeRTL(selection.focusNode);
    let focusIdx = rects.length - 1;
    this._cache.focusPt = (this._focusIsRTL) ?
      new Point(rects[focusIdx].left, rects[focusIdx].bottom) :
      new Point(rects[focusIdx].right, rects[focusIdx].bottom);
  },

  /*
   * Return true if text associated with a node is RTL.
   */
  _isNodeRTL: function(node) {
    // Find containing node that supports .direction attribute (needed
    // when target node is #text for example).
    while (node && !(node instanceof Element)) {
      node = node.parentNode;
    }

    // Worst case, use original direction from _targetElement.
    if (!node) {
      return this._targetIsRTL;
    }

    let nodeWin = node.ownerDocument.defaultView;
    let nodeStyle = nodeWin.getComputedStyle(node, "");
    return (nodeStyle.direction == "rtl");
  },

  _getHandlePositions: function(scroll = this._getScrollPos()) {
    // the checkHidden function tests to see if the given point is hidden inside an
    // iframe/subdocument. this is so that if we select some text inside an iframe and
    // scroll the iframe so the selection is out of view, we hide the handles rather
    // than having them float on top of the main page content.
    let checkHidden = function(x, y) {
      return false;
    };
    if (this._contentWindow.frameElement) {
      let bounds = this._contentWindow.frameElement.getBoundingClientRect();
      checkHidden = function(x, y) {
        return x < 0 || y < 0 || x > bounds.width || y > bounds.height;
      };
    }

    if (this._activeType == this.TYPE_CURSOR) {
      // The left and top properties returned are relative to the client area
      // of the window, so we don't need to account for a sub-frame offset.
      let cursor = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_CARET_RECT, this._targetElement.selectionEnd, 0, 0, 0,
                                                           this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
      // the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so
      // divide by the pixel ratio
      let x = cursor.left / window.devicePixelRatio;
      let y = (cursor.top + cursor.height) / window.devicePixelRatio;
      return [{ handle: this.HANDLE_TYPE_CARET,
                left: x + scroll.X,
                top: y + scroll.Y,
                rtl: this._targetIsRTL,
                hidden: checkHidden(x, y) }];
    }

    // Determine the handle screen coords
    this._updateCacheForSelection();
    let offset = this._getViewOffset();
    return  [{ handle: this.HANDLE_TYPE_ANCHOR,
               left: this._cache.anchorPt.x + offset.x + scroll.X,
               top: this._cache.anchorPt.y + offset.y + scroll.Y,
               rtl: this._anchorIsRTL,
               hidden: checkHidden(this._cache.anchorPt.x, this._cache.anchorPt.y) },
             { handle: this.HANDLE_TYPE_FOCUS,
               left: this._cache.focusPt.x + offset.x + scroll.X,
               top: this._cache.focusPt.y + offset.y + scroll.Y,
               rtl: this._focusIsRTL,
               hidden: checkHidden(this._cache.focusPt.x, this._cache.focusPt.y) }];
  },

  // Position handles, but avoid superfluous re-positioning (helps during
  // "TextSelection:LayerReflow", "scroll" of top-level document, etc).
  _positionHandlesOnChange: function() {
    // Helper function to compare position messages
    let samePositions = function(aPrev, aCurr) {
      if (aPrev.length != aCurr.length) {
        return false;
      }
      for (let i = 0; i < aPrev.length; i++) {
        if (aPrev[i].left != aCurr[i].left ||
            aPrev[i].top != aCurr[i].top ||
            aPrev[i].rtl != aCurr[i].rtl ||
            aPrev[i].hidden != aCurr[i].hidden) {
          return false;
        }
      }
      return true;
    }

    let positions = this._getHandlePositions();
    if (!samePositions(this._prevHandlePositions, positions)) {
      this._positionHandles(positions);
    }
  },

  // Position handles, allow for re-position, in case user drags handle
  // to invalid position, then releases, we can put it back where it started
  // positions is an array of objects with data about handle positions,
  // which we get from _getHandlePositions.
  _positionHandles: function(positions = this._getHandlePositions()) {
    Messaging.sendRequest({
      type: "TextSelection:PositionHandles",
      positions: positions,
    });
    this._prevHandlePositions = positions;

    // Text state transitions (text <--> no text) will affect selection context and actionbar display
    let currTargetElementHasText = (this._targetElement.textLength > 0);
    if (currTargetElementHasText != this._prevTargetElementHasText) {
      this._prevTargetElementHasText = currTargetElementHasText;
      this._updateMenu();
    }
  },

  subdocumentScrolled: function sh_subdocumentScrolled(aElement) {
    // Ignore all but selectionListener notifications during deferred _closeSelection().
    if (this._deferCloseTimer) {
      return;
    }

    if (this._activeType == this.TYPE_NONE) {
      return;
    }
    let scrollView = aElement.ownerDocument.defaultView;
    let view = this._contentWindow;
    while (true) {
      if (view == scrollView) {
        // The selection is in a view (or sub-view) of the view that scrolled.
        // So we need to reposition the handles.
        this._positionHandles();
        break;
      }
      if (view == view.parent) {
        break;
      }
      view = view.parent;
    }
  }
};
