Source: mentions.js

/** @module mentions */

var AJAXController = require("./controller").AJAXController,
    Controller = require("./controller").Controller,
    View = require("./view");

var extend = require("./utilities/extend"),
    defaultFactory = require("./defaults/defaults"), // keep in defaults so we can write specific defaults for each object
    KEYUP = require("./keyboard").keyup,
    KEYDOWN = require("./keyboard").keydown;

module.exports = QuillMentions;

/**
 * @constructor
 * @param {Object} quill - An instance of `Quill`.
 * @param {Object} [options] - User configuration passed to the mentions module. It's mixed in with defaults.
 * @prop {Quill} quill
 * @prop {HTMLElement} container - Container for the popover (a.k.a. the View)
 * @prop {RegExp} matcher - Used to scan contents of editor for mentions.
 */
function QuillMentions(quill, options) {

    this.quill = quill;
    var modOptions = defaultFactory(options),
        container = this.quill.addContainer(modOptions.containerClassName);

    this.triggerSymbol = modOptions.triggerSymbol;
    this.includeTrigger = modOptions.includeTrigger;
    this.matcher = modOptions.matcher;
    this.mentionClass = modOptions.mentionClass;
    this.currentMention = null;

    this.selectedChoiceIndex = -1;

    this.setView(container, modOptions)
        .setController(modOptions)
        .listenTextChange(quill)
        .listenSelectionChange(quill)
        .listenClick(container)
        .addFormat();

    if (modOptions.hotkeys) {
        this.listenHotKeys(quill);
    }

    this._cachedRange = null;
    this.charSinceMention = 0;
}

/**
 * Sets QuillMentions.view to a View object
 * @method
 * @private
 * @param {HTMLElement} container
 * @param {Object} options - Configuration for the view
 */
QuillMentions.prototype.setView = function(container, options) {
    var templates = {},
        errMessage = options.noMatchMessage,
        marginTop = options.marginTop;
    templates.list = options.template;
    templates.listItem = options.choiceTemplate;
    templates.error = options.noMatchTemplate;
    this.view = new View(container, templates, {errMessage: errMessage, marginTop: marginTop });
    return this;
};

/**
 * Sets QuillMentions.controller to a Controller or AJAXController object (depending on options).
 * @method
 * @private
 * @param {Object} options - Configuration for the controller.
 */
QuillMentions.prototype.setController = function(options) {
    if (!this.view) throw new Error("Must set view before controller.");

    var formatter,
        config = {
            max: options.choiceMax,
        };
    if (!options.ajax) {
        formatter = options.format;
        config.data = options.choices;
        this.controller = new Controller(formatter, this.view, config);
    } else {
        formatter = options.ajax.format;
        extend(config, options.ajax);
        this.controller = new AJAXController(formatter, this.view, config);
    }
    return this;
};

/**
 * Sets a listener for text-change events on the given Quill instance
 * @method
 * @param {Quill} quill - An instance of Quill
 */
QuillMentions.prototype.listenTextChange = function listenTextChange(quill) {
    var eventName = this.quill.constructor.events.TEXT_CHANGE;
    quill.on(eventName, textChangeHandler.bind(this));
    return this;

    function textChangeHandler(delta, source) {
        if (source === "api") return;
        var mention = this.findMention(),
            query,
            _this;

        if (mention) {
            _this = this;

            this.charSinceMention = 0;
            this._cachedRange = quill.getSelection();
            this.currentMention = mention;
            this._addTemporaryMentionSpan();

            query = mention[0].replace(this.triggerSymbol, "");

            this.controller.search(query, function() {
                _this.view.show(_this.quill);
            });
        }
        else {
            this.charSinceMention++;
            this.view.hide();
        }
    }
};


/**
 * Sets a listener for selection-change events on the given Quill instance
 * @method
 * @param {Quill} quill - An instance of Quill
 */
QuillMentions.prototype.listenSelectionChange = function(quill) {
    var eventName = quill.constructor.events.SELECTION_CHANGE;
    quill.on(eventName, selectionChangeHandler.bind(this));
    return this;

    function selectionChangeHandler(range) {
        if (!range) this.view.hide();
    }
};

/**
 * Sets a listener for keyboard events on the given container.
 * Events are dispatched through the KEYS object.
 * @method
 * @param {Quill} quill - An instance of Quill
 */
QuillMentions.prototype.listenHotKeys = function(quill) {
    quill.container
        .addEventListener('keydown',
                           keydownHandler.bind(this)); // TIL keypress is intended for keys that normally produce a character

    quill.container
        .addEventListener('keyup',
                           keyupHandler.bind(this));

    return this;

    function keydownHandler(event) {
        var code = event.keyCode || event.which;
        if (!this.view.isHidden()) { // need special logic for enter key :sob:
            if (KEYDOWN[code]) {
                KEYDOWN[code].call(this);
                event.stopPropagation();
                event.preventDefault();
            }
        }
    }
    function keyupHandler(event) {
        var code = event.keyCode || event.which;
        if (!this.view.isHidden() || this.charSinceMention === 1) { // this weird if condition solve an issue where hitting enter would hide the view and we wouldn't be able to insert the mention...
            if (KEYUP[code]) {
                KEYUP[code].call(this);
            }
        }
    }
};

/**
 * Listens for a click or touchend event on the View.
 * @method
 * @param {HTMLElement} elt
 */
QuillMentions.prototype.listenClick = function(elt) {

    elt.addEventListener("click", addMention.bind(this));
    elt.addEventListener("touchend", addMention.bind(this));
    return this;

    /** Wraps the QuillMentions~addMention method */
    function addMention(event) {
        var target = event.target || event.srcElement;
        if (target.tagName.toLowerCase() === "li") {
            this.addMention(target);
        }
        event.stopPropagation();
    }
};

/**
 * Looks for a string in the editor (up to the cursor's current position) for a match to QuillMentions~matcher
 * @method
 * @return {Match}
 */
QuillMentions.prototype.findMention = function findMention() {
    var range = this.quill.getSelection() || this._cachedRange,
        cursor = range.end,
        contents = this.quill.getText(0, cursor);

    return this.matcher.exec(contents);
};

/**
 * Needs to be refactored! QuillMention should be responsible for this action.
 * @method
 * @param {HTMLElement}
 */
QuillMentions.prototype.addMention = function addMention(node) {
    var insertAt = this.currentMention.index,
        toInsert = (this.includeTrigger ? this.triggerSymbol : "") + node.dataset.display,
        toFocus = insertAt + toInsert.length + 1;


    this.quill.deleteText(insertAt, insertAt + this.currentMention[0].length);
    this.quill.insertText(insertAt, toInsert, "mention", this.mentionClass+"-"+node.dataset.mention);
    this.quill.insertText(insertAt + toInsert.length, " ");
    this.quill.setSelection(toFocus, toFocus);

    this.view.hide();
};

QuillMentions.prototype._addTemporaryMentionSpan = function(range) {
    var insertAt = this.currentMention.index,
        toInsert = this.currentMention[0];

    this.quill.deleteText(insertAt, insertAt + this.currentMention[0].length);
    this.quill.insertText(insertAt, toInsert, "mention", "is-typing-mention");
};


 /**
  * Refactor.
  * @method
  */
 QuillMentions.prototype.hasSelection = function() {
     return this.selectedChoiceIndex !== -1;
 };

/**
 * Waiting on new custom formats in Quill to beef this up.
 * @method
 * @private
 */
 QuillMentions.prototype.addFormat = function(className) {
     this.quill.addFormat('mention', { tag: 'SPAN', "class": "ql-", }); // a mention is a span with the class prefix "ql-". the naming is an artifact of the current custom formats implementation
     return this;
 };