Source: editor/editor.js

import Tooltip from '../views/tooltip';
import PostEditor from './post';
import ImageCard from '../cards/image';
import { DIRECTION } from '../utils/key';
import mobiledocParsers from '../parsers/mobiledoc';
import HTMLParser from '../parsers/html';
import DOMParser from '../parsers/dom';
import Renderer  from 'mobiledoc-kit/renderers/editor-dom';
import RenderTree from 'mobiledoc-kit/models/render-tree';
import mobiledocRenderers from '../renderers/mobiledoc';
import { MOBILEDOC_VERSION } from 'mobiledoc-kit/renderers/mobiledoc';
import { mergeWithOptions } from '../utils/merge';
import { normalizeTagName, clearChildNodes } from '../utils/dom-utils';
import { forEach, filter, contains, values } from '../utils/array-utils';
import { setData } from '../utils/element-utils';
import Cursor from '../utils/cursor';
import Range from '../utils/cursor/range';
import Position from '../utils/cursor/position';
import PostNodeBuilder from '../models/post-node-builder';
import { DEFAULT_TEXT_INPUT_HANDLERS } from './text-input-handlers';
import {
  DEFAULT_KEY_COMMANDS, buildKeyCommand, findKeyCommands, validateKeyCommand
} from './key-commands';
import { CARD_MODES } from '../models/card';
import { detect } from '../utils/array-utils';
import assert from '../utils/assert';
import MutationHandler from 'mobiledoc-kit/editor/mutation-handler';
import EditHistory from 'mobiledoc-kit/editor/edit-history';
import EventManager from 'mobiledoc-kit/editor/event-manager';
import EditState from 'mobiledoc-kit/editor/edit-state';
import HTMLRenderer from 'mobiledoc-html-renderer';
import TextRenderer from 'mobiledoc-text-renderer';
import LifecycleCallbacks from 'mobiledoc-kit/models/lifecycle-callbacks';
import LogManager from 'mobiledoc-kit/utils/log-manager';
import toRange from 'mobiledoc-kit/utils/to-range';
import MobiledocError from 'mobiledoc-kit/utils/mobiledoc-error';

// This export may later be deprecated, but re-export it from the renderer here
// for consumers that may depend on it.
export { EDITOR_ELEMENT_CLASS_NAME } from 'mobiledoc-kit/renderers/editor-dom';

const defaults = {
  placeholder: 'Write here...',
  spellcheck: true,
  autofocus: true,
  undoDepth: 5,
  cards: [],
  atoms: [],
  cardOptions: {},
  unknownCardHandler: ({env}) => {
    throw new MobiledocError(`Unknown card encountered: ${env.name}`);
  },
  unknownAtomHandler: ({env}) => {
    throw new MobiledocError(`Unknown atom encountered: ${env.name}`);
  },
  mobiledoc: null,
  html: null
};

const CALLBACK_QUEUES = {
  DID_UPDATE: 'didUpdate',
  WILL_RENDER: 'willRender',
  DID_RENDER: 'didRender',
  WILL_DELETE: 'willDelete',
  DID_DELETE: 'didDelete',
  WILL_HANDLE_NEWLINE: 'willHandleNewline',
  CURSOR_DID_CHANGE: 'cursorDidChange',
  DID_REPARSE: 'didReparse',
  POST_DID_CHANGE: 'postDidChange',
  INPUT_MODE_DID_CHANGE: 'inputModeDidChange'
};

/**
 * The Editor is a core component of mobiledoc-kit. After instantiating
 * an editor, use {@link Editor#render} to display the editor on the web page.
 *
 * An editor uses a {@link Post} internally to represent the displayed document.
 * The post can be serialized as mobiledoc using {@link Editor#serialize}. Mobiledoc
 * is the transportable "over-the-wire" format (JSON) that is suited for persisting
 * and sharing between editors and renderers (for display, e.g.), whereas the Post
 * model is better suited for programmatic editing.
 *
 * The editor will call registered callbacks for certain state changes. These are:
 *   * {@link Editor#cursorDidChange} -- The cursor position or selection changed.
 *   * {@link Editor#postDidChange} -- The contents of the post changed due to user input or
 *     programmatic editing. This hook can be used with {@link Editor#serialize}
 *     to auto-save a post as it is being edited.
 *   * {@link Editor#inputModeDidChange} -- The active section(s) or markup(s) at the current cursor
 *     position or selection have changed. This hook can be used with
 *     {@link Editor#activeMarkups} and {@link Editor#activeSections} to implement
 *     a custom toolbar.
 *   * {@link Editor#onTextInput} -- Register callbacks when the user enters text
 *     that matches a given string or regex.
 */
class Editor {
  /**
   * @param {Object} [options]
   * @param {Object} [options.mobiledoc] The mobiledoc to load into the editor.
   *        Supersedes `options.html`.
   * @param {String|DOM} [options.html] The html (as a string or DOM fragment)
   *        to parse and load into the editor.
   *        Will be ignored if `options.mobiledoc` is also passed.
   * @param {Array} [options.parserPlugins=[]]
   * @param {Array} [options.cards=[]] The cards that the editor may render.
   * @param {Array} [options.atoms=[]] The atoms that the editor may render.
   * @param {Function} [options.unknownCardHandler] Invoked by the editor's renderer
   *        whenever it encounters an unknown card.
   * @param {Function} [options.unknownAtomHandler] Invoked by the editor's renderer
   *        whenever it encounters an unknown atom.
   * @param {String} [options.placeholder] Default text to show before user starts typing.
   * @param {Boolean} [options.spellcheck=true] Whether to enable spellcheck
   * @param {Boolean} [options.autofocus=true] Whether to focus the editor when it is first rendered.
   * @param {number} [options.undoDepth=5] How many undo levels will be available.
   *        Set to 0 to disable undo/redo functionality.
   * @return {Editor}
   * @public
   */
  constructor(options={}) {
    assert('editor create accepts an options object. For legacy usage passing an element for the first argument, consider the `html` option for loading DOM or HTML posts. For other cases call `editor.render(domNode)` after editor creation',
          (options && !options.nodeType));
    this._views = [];
    this.isEditable = null;
    this._parserPlugins = options.parserPlugins || [];

    // FIXME: This should merge onto this.options
    mergeWithOptions(this, defaults, options);
    this.cards.push(ImageCard);

    DEFAULT_KEY_COMMANDS.forEach(kc => this.registerKeyCommand(kc));

    this._logManager = new LogManager();
    this._parser   = new DOMParser(this.builder);
    let {cards, atoms, unknownCardHandler, unknownAtomHandler, cardOptions} = this;
    this._renderer = new Renderer(this, cards, atoms, unknownCardHandler, unknownAtomHandler, cardOptions);

    this.post = this.loadPost();
    this._renderTree = new RenderTree(this.post);

    this._editHistory = new EditHistory(this, this.undoDepth);
    this._eventManager = new EventManager(this);
    this._mutationHandler = new MutationHandler(this);
    this._editState = new EditState(this);
    this._callbacks = new LifecycleCallbacks(values(CALLBACK_QUEUES));

    DEFAULT_TEXT_INPUT_HANDLERS.forEach(handler => this.onTextInput(handler));

    this.hasRendered = false;
  }

  /**
   * Turns on verbose logging for the editor.
   * @param {Array} [logTypes=[]] If present, only the given log types will be logged.
   * @public
   */
  enableLogging(logTypes=[]) {
    if (logTypes.length === 0) {
      this._logManager.enableAll();
    } else {
      this._logManager.enableTypes(logTypes);
    }
  }

  /**
   * Disable all logging
   * @public
   */
  disableLogging() {
    this._logManager.disable();
  }

  /**
   * @private
   */
  loggerFor(type) {
    return this._logManager.for(type);
  }

  /**
   * The editor's instance of a post node builder.
   * @type {PostNodeBuilder}
   */
  get builder() {
    if (!this._builder) { this._builder = new PostNodeBuilder(); }
    return this._builder;
  }

  loadPost() {
    let {mobiledoc, html} = this;
    if (mobiledoc) {
      return mobiledocParsers.parse(this.builder, mobiledoc);
    } else if (html) {
      if (typeof html === 'string') {
        let options = {plugins: this._parserPlugins};
        return new HTMLParser(this.builder, options).parse(this.html);
      } else {
        let dom = html;
        return this._parser.parse(dom);
      }
    } else {
      return this.builder.createPost();
    }
  }

  rerender() {
    let postRenderNode = this.post.renderNode;

    // if we haven't rendered this post's renderNode before, mark it dirty
    if (!postRenderNode.element) {
      assert('Must call `render` before `rerender` can be called',
             this.hasRendered);
      postRenderNode.element = this.element;
      postRenderNode.markDirty();
    }

    this.runCallbacks(CALLBACK_QUEUES.WILL_RENDER);
    this._mutationHandler.suspendObservation(() => {
      this._renderer.render(this._renderTree);
    });
    this.runCallbacks(CALLBACK_QUEUES.DID_RENDER);
  }

  /**
   * @param {Element} element The DOM element to render into.
   *        Its contents will be replaced by the editor's rendered post.
   * @public
   */
  render(element) {
    assert('Cannot render an editor twice. Use `rerender` to update the ' +
           'rendering of an existing editor instance.',
           !this.hasRendered);

    element.spellcheck = this.spellcheck;

    clearChildNodes(element);

    this.element = element;

    if (this.isEditable === null) {
      this.enableEditing();
    }

    this._addTooltip();

    // A call to `run` will trigger the didUpdatePostCallbacks hooks with a
    // postEditor.
    this.run(() => {});

    // Only set `hasRendered` to true after calling `run` to ensure that
    // no cursorDidChange or other callbacks get fired before the editor is
    // done rendering
    this.hasRendered = true;
    this.rerender();

    this._mutationHandler.init();
    this._eventManager.init();

    if (this.autofocus) {
      this.selectRange(this.post.headPosition());
    }
  }

  _addTooltip() {
    this.addView(new Tooltip({
      rootElement: this.element,
      showForTag: 'a'
    }));
  }

  get keyCommands() {
    if (!this._keyCommands) { this._keyCommands = []; }
    return this._keyCommands;
  }

  /**
   * @param {Object} keyCommand The key command to register. It must specify a
   * modifier key (meta, ctrl, etc), a string representing the ascii key, and
   * a `run` method that will be passed the editor instance when the key command
   * is invoked
   * @public
   */
  registerKeyCommand(rawKeyCommand) {
    const keyCommand = buildKeyCommand(rawKeyCommand);
    assert('Key Command is not valid', validateKeyCommand(keyCommand));
    this.keyCommands.unshift(keyCommand);
  }

  /**
   * Convenience for {@link PostEditor#deleteAtPosition}. Deletes and puts the
   * cursor in the new position.
   * @public
   */
  deleteAtPosition(position, direction, {unit}) {
    this.run(postEditor => {
      let nextPosition = postEditor.deleteAtPosition(position, direction, {unit});
      postEditor.setRange(nextPosition);
    });
  }

  /**
   * Convenience for {@link PostEditor#deleteRange}. Deletes and puts the
   * cursor in the new position.
   * @param {Range} range
   * @public
   */
  deleteRange(range) {
    this.run(postEditor => {
      let nextPosition = postEditor.deleteRange(range);
      postEditor.setRange(nextPosition);
    });
  }

  /**
   * @private
   */
  performDelete({direction, unit}={direction: DIRECTION.BACKWARD, unit: 'char'}) {
    let { range } = this;

    this.runCallbacks(CALLBACK_QUEUES.WILL_DELETE, [range, direction, unit]);
    if (range.isCollapsed) {
      this.deleteAtPosition(range.head, direction, {unit});
    } else {
      this.deleteRange(range);
    }
    this.runCallbacks(CALLBACK_QUEUES.DID_DELETE, [range, direction, unit]);
  }

  handleNewline(event) {
    if (!this.hasCursor()) { return; }

    event.preventDefault();

    let { range } = this;
    this.run(postEditor => {
      let cursorSection;
      if (!range.isCollapsed) {
        let nextPosition  = postEditor.deleteRange(range);
        cursorSection = nextPosition.section;
        if (cursorSection && cursorSection.isBlank) {
          postEditor.setRange(cursorSection.headPosition());
          return;
        }
      }

      // Above logic might delete redundant range, so callback must run after it.
      let defaultPrevented = false;
      const event = { preventDefault() { defaultPrevented = true; } };
      this.runCallbacks(CALLBACK_QUEUES.WILL_HANDLE_NEWLINE, [event]);
      if (defaultPrevented) { return; }

      cursorSection = postEditor.splitSection(range.head)[1];
      postEditor.setRange(cursorSection.headPosition());
    });
  }

  /**
   * Notify the editor that the post did change, and run associated
   * callbacks.
   * @private
   */
  _postDidChange() {
    this.runCallbacks(CALLBACK_QUEUES.POST_DID_CHANGE);
  }

  /**
   * Selects the given range or position. If given a collapsed range or a position, this positions the cursor
   * at the range's position. Otherwise a selection is created in the editor
   * surface encompassing the range.
   * @param {Range|Position} range
   */
  selectRange(range) {
    range = toRange(range);

    this.cursor.selectRange(range);
    this.range = range;
  }

  get cursor() {
    return new Cursor(this);
  }

  /**
   * Return the current range for the editor (may be cached).
   * @return {Range}
   */
  get range() {
    return this._editState.range;
  }

  set range(newRange) {
    this._editState.updateRange(newRange);

    if (this._editState.rangeDidChange()) {
      this._rangeDidChange();
    }

    if (this._editState.inputModeDidChange()) {
      this._inputModeDidChange();
    }
  }

  _readRangeFromDOM() {
    this.range = this.cursor.offsets;
  }

  setPlaceholder(placeholder) {
    setData(this.element, 'placeholder', placeholder);
  }

  _reparsePost() {
    let post = this._parser.parse(this.element);
    this.run(postEditor => {
      postEditor.removeAllSections();
      postEditor.migrateSectionsFromPost(post);
      postEditor.setRange(Range.blankRange());
    });

    this.runCallbacks(CALLBACK_QUEUES.DID_REPARSE);
    this._postDidChange();
  }

  _reparseSections(sections=[]) {
    let currentRange;
    sections.forEach(section => {
      this._parser.reparseSection(section, this._renderTree);
    });
    this._removeDetachedSections();

    if (this._renderTree.isDirty) {
      currentRange = this.range;
    }

    // force the current snapshot's range to remain the same rather than
    // rereading it from DOM after the new character is applied and the browser
    // updates the cursor position
    let range = this._editHistory._pendingSnapshot.range;
    this.run(() => {
      this._editHistory._pendingSnapshot.range = range;
    });
    this.rerender();
    if (currentRange) {
      this.selectRange(currentRange);
    }

    this.runCallbacks(CALLBACK_QUEUES.DID_REPARSE);
    this._postDidChange();
  }

  // FIXME this should be able to be removed now -- if any sections are detached,
  // it's due to a bug in the code.
  _removeDetachedSections() {
    forEach(
      filter(this.post.sections, s => !s.renderNode.isAttached()),
      s => s.renderNode.scheduleForRemoval()
    );
  }

  /**
   * The sections from the cursor's selection start to the selection end
   * @type {Section[]}
   */
  get activeSections() {
    return this._editState.activeSections;
  }

  get activeSection() {
    const { activeSections } = this;
    return activeSections[activeSections.length - 1];
  }

  detectMarkupInRange(range, markupTagName) {
    let markups = this.post.markupsInRange(range);
    return detect(markups, markup => {
      return markup.hasTag(markupTagName);
    });
  }

  /**
   * @type {Markup[]}
   * @public
   */
  get activeMarkups() {
    return this._editState.activeMarkups;
  }

  /**
   * @param {Markup|String} markup A markup instance, or a string (e.g. "b")
   * @return {boolean}
   */
  hasActiveMarkup(markup) {
    let matchesFn;
    if (typeof markup === 'string') {
      let tagName = normalizeTagName(markup);
      matchesFn = (m) => m.tagName === tagName;
    } else {
      matchesFn = (m) => m === markup;
    }

    return !!detect(this.activeMarkups, matchesFn);
  }

  /**
   * @param {String} version The mobiledoc version to serialize to.
   * @return {Mobiledoc} Serialized mobiledoc
   * @public
   */
  serialize(version=MOBILEDOC_VERSION) {
    return this.serializePost(this.post, 'mobiledoc', {version});
  }

  /**
   * Serialize the editor's post to the requested format.
   * Note that only mobiledoc format is lossless. If cards or atoms are present
   * in the post, the html and text formats will omit them in output because
   * the editor does not have access to the html and text versions of the
   * cards/atoms.
   * @param {string} format The format to serialize ('mobiledoc', 'text', 'html')
   * @return {Object|String} The editor's post, serialized to {format}
   * @public
   */
  serializeTo(format) {
    let post = this.post;
    return this.serializePost(post, format);
  }

  /**
   * @param {Post}
   * @param {String} format Same as {serializeTo}
   * @param {Object} [options]
   * @param {String} [options.version=MOBILEDOC_VERSION] version to serialize to
   * @return {Object|String}
   * @private
   */
  serializePost(post, format, options={}) {
    const validFormats = ['mobiledoc', 'html', 'text'];
    assert(`Unrecognized serialization format ${format}`,
           contains(validFormats, format));

    if (format === 'mobiledoc') {
      let version = options.version || MOBILEDOC_VERSION;
      return mobiledocRenderers.render(post, version);
    } else {
      let rendered;
      let mobiledoc = this.serializePost(post, 'mobiledoc');
      let unknownCardHandler = () => {};
      let unknownAtomHandler = () => {};
      let rendererOptions = { unknownCardHandler, unknownAtomHandler };

      switch (format) {
        case 'html':
          rendered = new HTMLRenderer(rendererOptions).render(mobiledoc);
          return rendered.result;
        case 'text':
          rendered = new TextRenderer(rendererOptions).render(mobiledoc);
          return rendered.result;
      }
    }
  }

  addView(view) {
    this._views.push(view);
  }

  removeAllViews() {
    this._views.forEach((v) => v.destroy());
    this._views = [];
  }

  /**
   * Whether the editor has a cursor (or a selected range).
   * It is possible for the editor to be focused but not have a selection.
   * In this case, key events will fire but the editor will not be able to
   * determine a cursor position, so they will be ignored.
   * @return {boolean}
   * @public
   */
  hasCursor() {
    return this.cursor.hasCursor();
  }

  /**
   * Tears down the editor's attached event listeners and views.
   * @public
   */
  destroy() {
    this.isDestroyed = true;
    if (this._hasSelection()) {
      this.cursor.clearSelection();
    }
    if (this._hasFocus()) {
      this.element.blur(); // FIXME This doesn't blur the element on IE11
    }
    this._mutationHandler.destroy();
    this._eventManager.destroy();
    this.removeAllViews();
    this._renderer.destroy();
    this._editState.destroy();
  }

  /**
   * Keep the user from directly editing the post using the keyboard and mouse.
   * Modification via the programmatic API is still permitted.
   * @see Editor#enableEditing
   * @public
   */
  disableEditing() {
    if (this.isEditable === false) { return; }

    this.isEditable = false;
    if (this.hasRendered) {
      this.element.setAttribute('contentEditable', false);
      this.setPlaceholder('');
      this.selectRange(Range.blankRange());
    }
  }

  /**
   * Allow the user to directly interact with editing a post via keyboard and mouse input.
   * Editor instances are editable by default. Use this method to re-enable
   * editing after disabling it.
   * @see Editor#disableEditing
   * @public
   */
  enableEditing() {
    this.isEditable = true;
    if (this.element) {
      this.element.setAttribute('contentEditable', true);
      this.setPlaceholder(this.placeholder);
    }
  }

  /**
   * Change a cardSection into edit mode
   * If called before the card has been rendered, it will be marked so that
   * it is rendered in edit mode when it gets rendered.
   * @param {CardSection} cardSection
   * @public
   */
  editCard(cardSection) {
    this._setCardMode(cardSection, CARD_MODES.EDIT);
  }

  /**
   * Change a cardSection into display mode
   * If called before the card has been rendered, it will be marked so that
   * it is rendered in display mode when it gets rendered.
   * @param {CardSection} cardSection
   * @return undefined
   * @public
   */
  displayCard(cardSection) {
    this._setCardMode(cardSection, CARD_MODES.DISPLAY);
  }

  /**
   * Run a new post editing session. Yields a block with a new {@link PostEditor}
   * instance. This instance can be used to interact with the post abstract.
   * Rendering will be deferred until after the callback is completed.
   *
   * Usage:
   * ```
   *   let markerRange = this.range;
   *   editor.run((postEditor) => {
   *     postEditor.deleteRange(markerRange);
   *     // editing surface not updated yet
   *     postEditor.schedule(() => {
   *       console.log('logs during rerender flush');
   *     });
   *     // logging not yet flushed
   *   });
   *   // editing surface now updated.
   *   // logging now flushed
   * ```
   *
   * @param {Function} callback Called with an instance of
   *        {@link PostEditor} as its argument.
   * @return {Mixed} The return value of `callback`.
   * @public
   */
  run(callback) {
    const postEditor = new PostEditor(this);
    postEditor.begin();
    this._editHistory.snapshot();
    const result = callback(postEditor);
    this.runCallbacks(CALLBACK_QUEUES.DID_UPDATE, [postEditor]);
    postEditor.complete();
    this._readRangeFromDOM();

    if (postEditor._shouldCancelSnapshot) {
      this._editHistory._pendingSnapshot = null;
    }
    this._editHistory.storeSnapshot();

    return result;
  }

  /**
   * @param {Function} callback Called with `postEditor` as its argument.
   * @public
   */
  didUpdatePost(callback) {
    this.addCallback(CALLBACK_QUEUES.DID_UPDATE, callback);
  }

  /**
   * @param {Function} callback Called when the post has changed, either via
   *        user input or programmatically. Use with {@link Editor#serialize} to
   *        retrieve the post in portable mobiledoc format.
   */
  postDidChange(callback) {
    this.addCallback(CALLBACK_QUEUES.POST_DID_CHANGE, callback);
  }

  /**
   * Register a handler that will be invoked by the editor after the user enters
   * matching text.
   * @param {Object} inputHandler
   * @param {String} inputHandler.name Required. Used by identifying handlers.
   * @param {String} [inputHandler.text] Required if `match` is not provided
   * @param {RegExp} [inputHandler.match] Required if `text` is not provided
   * @param {Function} inputHandler.run This callback is invoked with the {@link Editor}
   *                   instance and an array of matches. If `text` was provided,
   *                   the matches array will equal [`text`], and if a `match`
   *                   regex was provided the matches array will be the result of
   *                   `match.exec` on the matching text. The callback is called
   *                   after the matching text has been inserted.
   * @public
   */
  onTextInput(inputHandler) {
    this._eventManager.registerInputHandler(inputHandler);
  }

  /**
   * Unregister all text input handlers
   *
   * @public
   */
  unregisterAllTextInputHandlers() {
    this._eventManager.unregisterAllTextInputHandlers();
  }

  /**
   * Unregister text input handler by name
   * @param {String} name The name of handler to be removed
   *
   * @public
   */
  unregisterTextInputHandler(name) {
    this._eventManager.unregisterInputHandler(name);
  }

  /**
   * @param {Function} callback Called when the editor's state (active markups or
   * active sections) has changed, either via user input or programmatically
   */
  inputModeDidChange(callback) {
    this.addCallback(CALLBACK_QUEUES.INPUT_MODE_DID_CHANGE, callback);
  }

  /**
   * @param {Function} callback This callback will be called before the editor
   *        is rendered.
   * @public
   */
  willRender(callback) {
    this.addCallback(CALLBACK_QUEUES.WILL_RENDER, callback);
  }

  /**
   * @param {Function} callback This callback will be called after the editor
   *        is rendered.
   * @public
   */
  didRender(callback) {
    this.addCallback(CALLBACK_QUEUES.DID_RENDER, callback);
  }

  /**
   * @param {Function} callback This callback will be called before deleting.
   * @public
   */
  willDelete(callback) {
    this.addCallback(CALLBACK_QUEUES.WILL_DELETE, callback);
  }

  /**
   * @param {Function} callback This callback will be called after deleting.
   * @public
   */
  didDelete(callback) {
    this.addCallback(CALLBACK_QUEUES.DID_DELETE, callback);
  }

  /**
   * @param {Function} callback This callback will be called before handling new line.
   * @public
   */
  willHandleNewline(callback) {
    this.addCallback(CALLBACK_QUEUES.WILL_HANDLE_NEWLINE, callback);
  }

  /**
   * @param {Function} callback This callback will be called every time the cursor
   *        position (or selection) changes.
   * @public
   */
  cursorDidChange(callback) {
    this.addCallback(CALLBACK_QUEUES.CURSOR_DID_CHANGE, callback);
  }

  _rangeDidChange() {
    if (this.hasRendered) {
      this.runCallbacks(CALLBACK_QUEUES.CURSOR_DID_CHANGE);
    }
  }

  _inputModeDidChange() {
    this.runCallbacks(CALLBACK_QUEUES.INPUT_MODE_DID_CHANGE);
  }

  _insertEmptyMarkupSectionAtCursor() {
    this.run(postEditor => {
      const section = postEditor.builder.createMarkupSection('p');
      postEditor.insertSectionBefore(this.post.sections, section);
      postEditor.setRange(section.toRange());
    });
  }

  /**
   * Toggles the given markup at the editor's current {@link Range}.
   * If the range is collapsed this changes the editor's state so that the
   * next characters typed will be affected. If there is text selected
   * (aka a non-collapsed range), the selections' markup will be toggled.
   * If the editor is not focused and has no active range, nothing happens.
   * @param {String} markup E.g. "b", "em", "a"
   * @public
   * @see PostEditor#toggleMarkup
   */
  toggleMarkup(markup) {
    markup = this.builder.createMarkup(markup);
    let { range } = this;
    if (range.isCollapsed) {
      this._editState.toggleMarkupState(markup);
      this._inputModeDidChange();

      // when clicking a button to toggle markup, the button can end up being focused,
      // so ensure the editor is focused
      this._ensureFocus();
    } else {
      this.run(postEditor => postEditor.toggleMarkup(markup, range));
    }
  }

  // If the editor has a selection but is not focused, focus it
  _ensureFocus() {
    if (this._hasSelection() && !this._hasFocus()) {
      this.focus();
    }
  }

  focus() {
    this.element.focus();
  }

  /**
   * Whether there is a selection inside the editor's element.
   * It's possible to have a selection but not have focus.
   * @see #_hasFocus
   * @return {Boolean}
   */
  _hasSelection() {
    let { cursor } = this;
    return this.hasRendered && (cursor._hasCollapsedSelection() || cursor._hasSelection());
  }

  /**
   * Whether the editor's element is focused
   * It's possible to be focused but have no selection
   * @see #_hasSelection
   * @return {Boolean}
   */
  _hasFocus() {
    return document.activeElement === this.element;
  }

  /**
   * Toggles the tagName for the current active section(s). This will skip
   * non-markerable sections. E.g. if the editor's range includes a "P" MarkupSection
   * and a CardSection, only the MarkupSection will be toggled.
   * @param {String} tagName The new tagname to change to.
   * @public
   * @see PostEditor#toggleSection
   */
  toggleSection(tagName) {
    this.run(postEditor => postEditor.toggleSection(tagName, this.range));
  }

  /**
   * Finds and runs the first matching key command for the event
   *
   * If multiple commands are bound to a key combination, the
   * first matching one is run.
   *
   * If a command returns `false` then the next matching command
   * is run instead.
   *
   * @param {Event} event The keyboard event triggered by the user
   * @return {Boolean} true when a command was successfully run
   * @private
   */
  handleKeyCommand(event) {
    const keyCommands = findKeyCommands(this.keyCommands, event);
    for (let i=0; i<keyCommands.length; i++) {
      let keyCommand = keyCommands[i];
      if (keyCommand.run(this) !== false) {
        event.preventDefault();
        return true;
      }
    }
    return false;
  }

  /**
   * Inserts the text at the current cursor position. If the editor has
   * no current cursor position, nothing will be inserted. If the editor's
   * range is not collapsed, it will be deleted before insertion.
   *
   * @param {String} text
   * @public
   */
  insertText(text) {
    if (!this.hasCursor()) { return; }
    if (this.post.isBlank) {
      this._insertEmptyMarkupSectionAtCursor();
    }
    let { activeMarkups, range, range: { head: position } } = this;

    this.run(postEditor => {
      if (!range.isCollapsed) {
        position = postEditor.deleteRange(range);
      }

      postEditor.insertTextWithMarkup(position, text, activeMarkups);
    });
  }

  /**
   * Inserts an atom at the current cursor position. If the editor has
   * no current cursor position, nothing will be inserted. If the editor's
   * range is not collapsed, it will be deleted before insertion.
   * @param {String} atomName
   * @param {String} [atomText='']
   * @param {Object} [atomPayload={}]
   * @return {Atom} The inserted atom.
   * @public
   */
  insertAtom(atomName, atomText='', atomPayload={}) {
    if (!this.hasCursor()) { return; }
    if (this.post.isBlank) {
      this._insertEmptyMarkupSectionAtCursor();
    }

    let atom;
    let { range } = this;
    this.run(postEditor => {
      let position = range.head;

      atom = postEditor.builder.createAtom(atomName, atomText, atomPayload);
      if (!range.isCollapsed) {
        position = postEditor.deleteRange(range);
      }

      postEditor.insertMarkers(position, [atom]);
    });
    return atom;
  }

  /**
   * Inserts a card at the section after the current cursor position. If the editor has
   * no current cursor position, nothing will be inserted. If the editor's
   * range is not collapsed, it will be deleted before insertion. If the cursor is in
   * a blank section, it will be replaced with a card section.
   * The editor's cursor will be placed at the end of the inserted card.
   * @param {String} cardName
   * @param {Object} [cardPayload={}]
   * @param {Boolean} [inEditMode=false] Whether the card should be inserted in edit mode.
   * @return {Card} The inserted Card section.
   * @public
   */
  insertCard(cardName, cardPayload={}, inEditMode=false) {
    if (!this.hasCursor()) { return; }
    if (this.post.isBlank) {
      this._insertEmptyMarkupSectionAtCursor();
    }

    let card;
    let { range } = this;
    this.run(postEditor => {
      let position = range.tail;
      card = postEditor.builder.createCardSection(cardName, cardPayload);
      if (inEditMode) {
        this.editCard(card);
      }

      if (!range.isCollapsed) {
        position = postEditor.deleteRange(range);
      }

      let section = position.section;
      if (section.isNested) { section = section.parent; }

      if (section.isBlank) {
        postEditor.replaceSection(section, card);
      } else {
        let collection = this.post.sections;
        postEditor.insertSectionBefore(collection, card, section.next);
      }

      // It is important to explicitly set the range to the end of the card.
      // Otherwise it is possible to create an inconsistent state in the
      // browser. For instance, if the user clicked a button that
      // called `editor.insertCard`, the editor surface may retain
      // the selection but lose focus, and the next keystroke by the user
      // will cause an unexpected DOM mutation (which can wipe out the
      // card).
      // See: https://github.com/bustlelabs/mobiledoc-kit/issues/286
      postEditor.setRange(card.tailPosition());
    });
    return card;
  }

  /**
   * @param {integer} x x-position in viewport
   * @param {integer} y y-position in viewport
   * @return {Position|null}
   */
  positionAtPoint(x, y) {
    return Position.atPoint(x, y, this);
  }

  /**
   * @private
   */
  _setCardMode(cardSection, mode) {
    const renderNode = cardSection.renderNode;
    if (renderNode && renderNode.isRendered) {
      const cardNode = renderNode.cardNode;
      cardNode[mode]();
    } else {
      cardSection.setInitialMode(mode);
    }
  }

  triggerEvent(context, eventName, event) {
    this._eventManager._trigger(context, eventName, event);
  }

  addCallback(...args) {
    this._callbacks.addCallback(...args);
  }

  addCallbackOnce(...args) {
    this._callbacks.addCallbackOnce(...args);
  }

  runCallbacks(...args) {
    if (this.isDestroyed) {
      // TODO warn that callback attempted after editor was destroyed
      return;
    }
    this._callbacks.runCallbacks(...args);
  }
}

export default Editor;