browser/devtools/sourceeditor/source-editor-textarea.jsm
author Panos Astithas <past@mozilla.com>
Wed, 14 Sep 2011 10:25:43 -0700
changeset 76959 01ef83aa3f02aa7606eeda54b6381148da059324
parent 74309 9975c8487a09366aa21ba19555272cbe690ed6d7
child 82434 847e7bead2db3291fa5761d77df9cea20c004b7b
permissions -rw-r--r--
Bug 686705 - Source Editor with the textarea fallback displays undefined without a placeholderText; r=mihai.sucan

/* vim:set ft=javascript ts=2 sw=2 sts=2 et tw=80:
 * ***** BEGIN LICENSE BLOCK *****
 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is the Source Editor component (textarea fallback).
 *
 * The Initial Developer of the Original Code is
 * The Mozilla Foundation.
 * Portions created by the Initial Developer are Copyright (C) 2011
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s):
 *   Mihai Sucan <mihai.sucan@gmail.com> (original author)
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either the GNU General Public License Version 2 or later (the "GPL"), or
 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the MPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the MPL, the GPL or the LGPL.
 *
 * ***** END LICENSE BLOCK *****/

"use strict";

const Cu = Components.utils;
const Ci = Components.interfaces;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";

var EXPORTED_SYMBOLS = ["SourceEditor"];

/**
 * The SourceEditor object constructor. The SourceEditor component allows you to
 * provide users with an editor tailored to the specific needs of editing source
 * code, aimed primarily at web developers.
 *
 * The editor used here is a simple textarea. This is used as a fallback
 * mechanism for when the user disables the code editor feature.
 *
 * @constructor
 */
function SourceEditor() {
  // Update the SourceEditor defaults from user preferences.

  SourceEditor.DEFAULTS.TAB_SIZE =
    Services.prefs.getIntPref(SourceEditor.PREFS.TAB_SIZE);
  SourceEditor.DEFAULTS.EXPAND_TAB =
    Services.prefs.getBoolPref(SourceEditor.PREFS.EXPAND_TAB);

  this._listeners = {};
  this._lastSelection = {};
}

SourceEditor.prototype = {
  _textbox: null,
  _editor: null,
  _listeners: null,
  _lineDelimiter: null,
  _editActionListener: null,
  _expandTab: null,
  _tabSize: null,

  /**
   * The editor container element.
   * @type nsIDOMElement
   */
  parentElement: null,

  /**
   * Initialize the editor.
   *
   * @param nsIDOMElement aElement
   *        The DOM element where you want the editor to show.
   * @param object aConfig
   *        Editor configuration object. Properties:
   *          - placeholderText - the text you want to be shown by default.
   *          - mode - the editor mode, based on the file type you want to edit.
   *          You can use one of the predefined modes.
   *          - tabSize - define how many spaces to use for a tab character.
   *          - expandTab - tells if you want tab characters to be expanded to
   *          spaces.
   *          - readOnly - make the editor read only.
   *          - undoLimit - how many steps should the undo stack hold.
   * @param function [aCallback]
   *        Function you want to execute once the editor is loaded and
   *        initialized.
   */
  init: function SE_init(aElement, aConfig, aCallback)
  {
    if (this._textbox) {
      throw new Error("SourceEditor is already initialized!");
    }

    let doc = aElement.ownerDocument;
    let win = doc.defaultView;

    this._textbox = doc.createElementNS(XUL_NS, "textbox");
    this._textbox.flex = 1;
    this._textbox.setAttribute("multiline", true);
    this._textbox.setAttribute("dir", "ltr");

    aElement.appendChild(this._textbox);

    this.parentElement = aElement;
    this._editor = this._textbox.editor;

    this._expandTab = aConfig.expandTab !== undefined ?
                      aConfig.expandTab : SourceEditor.DEFAULTS.EXPAND_TAB;
    this._tabSize = aConfig.tabSize || SourceEditor.DEFAULTS.TAB_SIZE;

    this._textbox.style.MozTabSize = this._tabSize;

    this._textbox.setAttribute("value", aConfig.placeholderText || "");
    this._textbox.setAttribute("class", "monospace");
    this._textbox.style.direction = "ltr";
    this._textbox.readOnly = aConfig.readOnly;

    // Make sure that the SourceEditor Selection events are fired properly.
    // Also make sure that the Tab key inserts spaces when expandTab is true.
    this._textbox.addEventListener("select", this._onSelect.bind(this), false);
    this._textbox.addEventListener("keypress", this._onKeyPress.bind(this), false);
    this._textbox.addEventListener("keyup", this._onSelect.bind(this), false);
    this._textbox.addEventListener("click", this._onSelect.bind(this), false);

    // Mimic the mode change.
    this.setMode(aConfig.mode || SourceEditor.DEFAULTS.MODE);

    this._editor.transactionManager.maxTransactionCount =
      aConfig.undoLimit || SourceEditor.DEFAULTS.UNDO_LIMIT;

    // Make sure that the transactions stack is clean.
    this._editor.transactionManager.clear();
    this._editor.resetModificationCount();

    // Add the edit action listener so we can fire the SourceEditor TextChanged
    // events.
    this._editActionListener = new EditActionListener(this);
    this._editor.addEditActionListener(this._editActionListener);

    this._lineDelimiter = win.navigator.platform.indexOf("Win") > -1 ?
                          "\r\n" : "\n";

    this._config = aConfig;

    if (aCallback) {
      aCallback(this);
    }
  },

  /**
   * The textbox keypress event handler allows users to indent code using the
   * Tab key.
   *
   * @private
   * @param nsIDOMEvent aEvent
   *        The DOM object for the event.
   */
  _onKeyPress: function SE__onKeyPress(aEvent)
  {
    if (aEvent.keyCode != aEvent.DOM_VK_TAB || aEvent.shiftKey ||
        aEvent.metaKey || aEvent.ctrlKey || aEvent.altKey) {
      return;
    }

    aEvent.preventDefault();

    let caret = this.getCaretOffset();
    let indent = "\t";

    if (this._expandTab) {
      let text = this._textbox.value;
      let lineStart = caret;
      while (lineStart > 0) {
        let c = text.charAt(lineStart - 1);
        if (c == "\r" || c == "\n") {
          break;
        }
        lineStart--;
      }
      let offset = caret - lineStart;
      let spaces = this._tabSize - (offset % this._tabSize);
      indent = (new Array(spaces + 1)).join(" ");
    }

    this.setText(indent, caret, caret);
    this.setCaretOffset(caret + indent.length);
  },

  /**
   * The textbox keyup, click and select event handler tracks selection
   * changes. This method invokes the SourceEditor Selection event handlers.
   *
   * @see SourceEditor.EVENTS.SELECTION
   * @private
   */
  _onSelect: function SE__onSelect()
  {
    let selection = this.getSelection();
    selection.collapsed = selection.start == selection.end;
    if (selection.collapsed && this._lastSelection.collapsed) {
      this._lastSelection = selection;
      return; // just a cursor move.
    }

    if (this._lastSelection.start != selection.start ||
        this._lastSelection.end != selection.end) {
      let sendEvent = {
        oldValue: {start: this._lastSelection.start,
                   end: this._lastSelection.end},
        newValue: {start: selection.start, end: selection.end},
      };

      let listeners = this._listeners[SourceEditor.EVENTS.SELECTION] || [];
      listeners.forEach(function(aListener) {
        aListener.callback.call(null, sendEvent, aListener.data);
      }, this);

      this._lastSelection = selection;
    }
  },

  /**
   * The TextChanged event dispatcher. This method is called when a change in
   * the text occurs. All of the SourceEditor TextChanged event handlers are
   * notified about the lower level change.
   *
   * @see SourceEditor.EVENTS.TEXT_CHANGED
   * @see EditActionListener
   *
   * @private
   *
   * @param object aEvent
   *        The TextChanged event object that is going to be sent to the
   *        SourceEditor event handlers.
   */
  _onTextChanged: function SE__onTextChanged(aEvent)
  {
    let listeners = this._listeners[SourceEditor.EVENTS.TEXT_CHANGED] || [];
    listeners.forEach(function(aListener) {
      aListener.callback.call(null, aEvent, aListener.data);
    }, this);
  },

  /**
   * Get the editor element.
   *
   * @return nsIDOMElement
   *         In this implementation a xul:textbox is returned.
   */
  get editorElement() {
    return this._textbox;
  },

  /**
   * Add an event listener to the editor. You can use one of the known events.
   *
   * @see SourceEditor.EVENTS
   *
   * @param string aEventType
   *        The event type you want to listen for.
   * @param function aCallback
   *        The function you want executed when the event is triggered.
   * @param mixed [aData]
   *        Optional data to pass to the callback when the event is triggered.
   */
  addEventListener:
  function SE_addEventListener(aEventType, aCallback, aData)
  {
    const EVENTS = SourceEditor.EVENTS;
    let listener = {
      type: aEventType,
      data: aData,
      callback: aCallback,
    };

    if (aEventType == EVENTS.CONTEXT_MENU) {
      listener.domType = "contextmenu";
      listener.target = this._textbox;
      listener.handler = this._onContextMenu.bind(this, listener);
      listener.target.addEventListener(listener.domType, listener.handler, false);
    }

    if (!(aEventType in this._listeners)) {
      this._listeners[aEventType] = [];
    }

    this._listeners[aEventType].push(listener);
  },

  /**
   * Remove an event listener from the editor. You can use one of the known
   * events.
   *
   * @see SourceEditor.EVENTS
   *
   * @param string aEventType
   *        The event type you have a listener for.
   * @param function aCallback
   *        The function you have as the event handler.
   * @param mixed [aData]
   *        The optional data passed to the callback.
   */
  removeEventListener:
  function SE_removeEventListener(aEventType, aCallback, aData)
  {
    let listeners = this._listeners[aEventType];
    if (!listeners) {
      throw new Error("SourceEditor.removeEventListener() called for an " +
                      "unknown event.");
    }

    const EVENTS = SourceEditor.EVENTS;

    this._listeners[aEventType] = listeners.filter(function(aListener) {
      let isSameListener = aListener.type == aEventType &&
                           aListener.callback === aCallback &&
                           aListener.data === aData;
      if (isSameListener && aListener.domType) {
        aListener.target.removeEventListener(aListener.domType,
                                             aListener.handler, false);
      }
      return !isSameListener;
    }, this);
  },

  /**
   * The xul:textbox contextmenu event handler. This is used a wrapper for each
   * contextmenu event listener added by the SourceEditor client.
   *
   * @param object aListener
   *        The object that holds listener information, see this._listener and
   *        this.addEventListener().
   * @param nsIDOMEvent aDOMEvent
   *        The nsIDOMEvent object that triggered the context menu.
   */
  _onContextMenu: function SE__onContextMenu(aListener, aDOMEvent)
  {
    let input = this._textbox.inputField;
    let rect = this._textbox.getBoundingClientRect();

    // Prepare the event object we send to the event handler.
    let sendEvent = {
      x: aDOMEvent.clientX - rect.left + input.scrollLeft,
      y: aDOMEvent.clientY - rect.top + input.scrollTop,
      screenX: aDOMEvent.screenX,
      screenY: aDOMEvent.screenY,
    };

    aDOMEvent.preventDefault();
    aListener.callback.call(null, sendEvent, aListener.data);
  },

  /**
   * Undo a change in the editor.
   */
  undo: function SE_undo()
  {
    this._editor.undo(1);
  },

  /**
   * Redo a change in the editor.
   */
  redo: function SE_redo()
  {
    this._editor.redo(1);
  },

  /**
   * Check if there are changes that can be undone.
   *
   * @return boolean
   *         True if there are changes that can be undone, false otherwise.
   */
  canUndo: function SE_canUndo()
  {
    let isEnabled = {};
    let canUndo = {};
    this._editor.canUndo(isEnabled, canUndo);
    return canUndo.value;
  },

  /**
   * Check if there are changes that can be repeated.
   *
   * @return boolean
   *         True if there are changes that can be repeated, false otherwise.
   */
  canRedo: function SE_canRedo()
  {
    let isEnabled = {};
    let canRedo = {};
    this._editor.canRedo(isEnabled, canRedo);
    return canRedo.value;
  },

  /**
   * Start a compound change in the editor. Compound changes are grouped into
   * only one change that you can undo later, after you invoke
   * endCompoundChange().
   */
  startCompoundChange: function SE_startCompoundChange()
  {
    this._editor.beginTransaction();
  },

  /**
   * End a compound change in the editor.
   */
  endCompoundChange: function SE_endCompoundChange()
  {
    this._editor.endTransaction();
  },

  /**
   * Focus the editor.
   */
  focus: function SE_focus()
  {
    this._textbox.focus();
  },

  /**
   * Check if the editor has focus.
   *
   * @return boolean
   *         True if the editor is focused, false otherwise.
   */
  hasFocus: function SE_hasFocus()
  {
    return this._textbox.ownerDocument.activeElement ===
           this._textbox.inputField;
  },

  /**
   * Get the editor content, in the given range. If no range is given you get
   * the entire editor content.
   *
   * @param number [aStart=0]
   *        Optional, start from the given offset.
   * @param number [aEnd=content char count]
   *        Optional, end offset for the text you want. If this parameter is not
   *        given, then the text returned goes until the end of the editor
   *        content.
   * @return string
   *         The text in the given range.
   */
  getText: function SE_getText(aStart, aEnd)
  {
    let value = this._textbox.value || "";
    if (aStart === undefined || aStart === null) {
      aStart = 0;
    }
    if (aEnd === undefined || aEnd === null) {
      aEnd = value.length;
    }
    return value.substring(aStart, aEnd);
  },

  /**
   * Get the number of characters in the editor content.
   *
   * @return number
   *         The number of editor content characters.
   */
  getCharCount: function SE_getCharCount()
  {
    return this._textbox.textLength;
  },

  /**
   * Get the selected text.
   *
   * @return string
   *         The currently selected text.
   */
  getSelectedText: function SE_getSelectedText()
  {
    let selection = this.getSelection();
    return selection.start != selection.end ?
           this.getText(selection.start, selection.end) : "";
  },

  /**
   * Replace text in the source editor with the given text, in the given range.
   *
   * @param string aText
   *        The text you want to put into the editor.
   * @param number [aStart=0]
   *        Optional, the start offset, zero based, from where you want to start
   *        replacing text in the editor.
   * @param number [aEnd=char count]
   *        Optional, the end offset, zero based, where you want to stop
   *        replacing text in the editor.
   */
  setText: function SE_setText(aText, aStart, aEnd)
  {
    if (aStart === undefined) {
      this._textbox.value = aText;
    } else {
      if (aEnd === undefined) {
        aEnd = this._textbox.textLength;
      }

      let value = this._textbox.value || "";
      let removedText = value.substring(aStart, aEnd);
      let prefix = value.substr(0, aStart);
      let suffix = value.substr(aEnd);

      if (suffix) {
        this._editActionListener._setTextRangeEvent = {
          start: aStart,
          removedCharCount: removedText.length,
          addedCharCount: aText.length,
        };
      }

      this._textbox.value = prefix + aText + suffix;
    }
  },

  /**
   * Drop the current selection / deselect.
   */
  dropSelection: function SE_dropSelection()
  {
    let selection = this._editor.selection;
    selection.collapse(selection.focusNode, selection.focusOffset);
    this._onSelect();
  },

  /**
   * Select a specific range in the editor.
   *
   * @param number aStart
   *        Selection range start.
   * @param number aEnd
   *        Selection range end.
   */
  setSelection: function SE_setSelection(aStart, aEnd)
  {
    this._textbox.setSelectionRange(aStart, aEnd);
    this._onSelect();
  },

  /**
   * Get the current selection range.
   *
   * @return object
   *         An object with two properties, start and end, that give the
   *         selection range (zero based offsets).
   */
  getSelection: function SE_getSelection()
  {
    return {
      start: this._textbox.selectionStart,
      end: this._textbox.selectionEnd
    };
  },

  /**
   * Get the current caret offset.
   *
   * @return number
   *         The current caret offset.
   */
  getCaretOffset: function SE_getCaretOffset()
  {
    let selection = this.getSelection();
    return selection.start < selection.end ?
           selection.end : selection.start;
  },

  /**
   * Set the caret offset.
   *
   * @param number aOffset
   *        The new caret offset you want to set.
   */
  setCaretOffset: function SE_setCaretOffset(aOffset)
  {
    this.setSelection(aOffset, aOffset);
  },

  /**
   * Get the line delimiter used in the document being edited.
   *
   * @return string
   *         The line delimiter.
   */
  getLineDelimiter: function SE_getLineDelimiter()
  {
    return this._lineDelimiter;
  },

  /**
   * Set the source editor mode to the file type you are editing.
   *
   * Note: this implementation makes no difference between any of the available
   * modes.
   *
   * @param string aMode
   *        One of the predefined SourceEditor.MODES.
   */
  setMode: function SE_setMode(aMode)
  {
    // nothing to do here
  },

  /**
   * Get the current source editor mode.
   *
   * @return string
   *         Returns one of the predefined SourceEditor.MODES. In this
   *         implementation SourceEditor.MODES.TEXT is always returned.
   */
  getMode: function SE_getMode()
  {
    return SourceEditor.MODES.TEXT;
  },

  /**
   * Setter for the read-only state of the editor.
   * @param boolean aValue
   *        Tells if you want the editor to read-only or not.
   */
  set readOnly(aValue)
  {
    this._textbox.readOnly = aValue;
  },

  /**
   * Getter for the read-only state of the editor.
   * @type boolean
   */
  get readOnly()
  {
    return this._textbox.readOnly;
  },

  /**
   * Destroy/uninitialize the editor.
   */
  destroy: function SE_destroy()
  {
    for (let eventType in this._listeners) {
      this._listeners[eventType].forEach(function(aListener) {
        if (aListener.domType) {
          aListener.target.removeEventListener(aListener.domType,
                                               aListener.handler, false);
        }
      }, this);
    }

    this._editor.removeEditActionListener(this._editActionListener);
    this.parentElement.removeChild(this._textbox);
    this.parentElement = null;
    this._editor = null;
    this._textbox = null;
    this._config = null;
    this._listeners = null;
    this._lastSelection = null;
    this._editActionListener = null;
  },
};

/**
 * The nsIEditActionListener for the nsIEditor of the xul:textbox used by the
 * SourceEditor. This listener traces text changes such that SourceEditor
 * TextChanged event handlers get their events.
 *
 * @see
 * http://mxr.mozilla.org/mozilla-central/source/editor/idl/nsIEditActionListener.idl
 *
 * @constructor
 * @param object aSourceEditor
 *        An instance of the SourceEditor to notify when text changes happen.
 */
function EditActionListener(aSourceEditor) {
  this._sourceEditor = aSourceEditor;
}

EditActionListener.prototype = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIEditActionListener]),

  WillCreateNode: function() { },
  DidCreateNode: function() { },
  WillInsertNode: function() { },

  DidInsertNode: function EAL_DidInsertNode(aNode)
  {
    if (aNode.nodeType != aNode.TEXT_NODE) {
      return;
    }

    let event;

    if (this._setTextRangeEvent) {
      event = this._setTextRangeEvent;
      delete this._setTextRangeEvent;
    } else {
      event = {
        start: 0,
        removedCharCount: 0,
        addedCharCount: aNode.textContent.length,
      };
    }

    this._sourceEditor._onTextChanged(event);
  },

  WillDeleteNode: function() { },
  DidDeleteNode: function() { },
  WillSplitNode: function() { },
  DidSplitNode: function() { },
  WillJoinNodes: function() { },
  DidJoinNodes: function() { },
  WillInsertText: function() { },

  DidInsertText: function EAL_DidInsertText(aTextNode, aOffset, aString)
  {
    let event = {
      start: aOffset,
      removedCharCount: 0,
      addedCharCount: aString.length,
    };

    this._sourceEditor._onTextChanged(event);
  },

  WillDeleteText: function() { },

  DidDeleteText: function EAL_DidDeleteText(aTextNode, aOffset, aLength)
  {
    let event = {
      start: aOffset,
      removedCharCount: aLength,
      addedCharCount: 0,
    };

    this._sourceEditor._onTextChanged(event);
  },

  WillDeleteSelection: function EAL_WillDeleteSelection()
  {
    if (this._setTextRangeEvent) {
      return;
    }

    let selection = this._sourceEditor.getSelection();
    let str = this._sourceEditor.getSelectedText();

    let event = {
      start: selection.start,
      removedCharCount: str.length,
      addedCharCount: 0,
    };

    this._sourceEditor._onTextChanged(event);
  },

  DidDeleteSelection: function() { },
};