API Docs for: 3.11.0-git
Show:

File: src/sm-editor/js/editor-base.js

/*jshint expr:true, onevar:false */

Y.Node.DOM_EVENTS.paste = 1;

/**
Provides `Y.Editor.Base`, the core implementation of the SmugMug editor.

@module gallery-sm-editor
@submodule gallery-sm-editor-base
**/

/**
Base implementation of the SmugMug editor. Provides core editor functionality,
but no undo stack, keyboard shortcuts, etc.

Provides support for the following commands:

- insertHTML
- insertText

@class Editor.Base
@constructor
@extends View
**/

var doc          = Y.config.doc,
    win          = Y.config.win,
    getClassName = Y.ClassNameManager.getClassName,
    EDOM         = Y.Editor.DOM;

/**
Fired after this editor loses focus.

@event blur
**/
var EVT_BLUR = 'blur';

/**
Fired after this editor receives focus.

@event focus
**/
var EVT_FOCUS = 'focus';

/**
Fired after this editor is rendered.

@event render
**/
var EVT_RENDER = 'render';

/**
Fired when this editor's selection changes.

@event selectionChange
@param {Range} prevRange Range that was previously selected, or `null` if there
    was no previous selection.
@param {Range} range Range that's now selected, or `null` if the current
    selection is empty or outside the editor.
@param {Selection} selection Reference to this editor's Selection instance.
**/
var EVT_SELECTION_CHANGE = 'selectionChange';

var EditorBase = Y.Base.create('editorBase', Y.View, [], {
    // -- Public Properties ----------------------------------------------------

    /**
    CSS class names used by this editor.

    @property {Object} classNames
    @param {String} cursor Class name used for a placeholder node that
        represents the cursor position.
    @param {String} editor Class name used for the editor's container.
    @param {String} input Class name used for the WYSIWYG YUI Editor frame that
        will receive user input.
    **/
    classNames: {
        cursor: getClassName('sm-editor-cursor', true),
        editor: getClassName('sm-editor', true),
        input : getClassName('sm-editor-input', true)
    },

    /**
    `Y.Selection` instance representing the current document selection.

    The selection object's state always reflects the current selection, so it
    will update when the selection changes. If you need to retain the state of a
    past selection, hold onto a Range instance representing that selection.

    Also, beware: this selection object reflects the current selection in the
    entire browser document, not just within this editor.

    @property {Selection} selection
    **/

    /**
    Hash of commands supported by this editor.

    Names should correspond with valid `execCommand()` command names. Values
    are properties in the following format:

    @property {Object} commands
        @param {Function|String} commandFn
        @param {Function|String} [queryFn]
    **/
    commands: {
        insertHTML: {
            commandFn: '_insertHTML'
        },

        insertText: {
            commandFn: '_insertText'
        }
    },


    supportedTags: 'a, br, div, p, span',

    // -- Protected Properties -------------------------------------------------

    // -- Lifecycle ------------------------------------------------------------

    initializer: function () {
        this.selection  = new Y.Selection();
        this.selectors  = {};

        this._cursorHTML = '<span class="' + this.classNames.cursor + '"></span>';

        Y.Object.each(this.classNames, function (name, key) {
            this.selectors[key] = '.' + name;
        }, this);

        this._attachEvents();
    },

    destructor: function () {
        this._detachEvents();

        this.selection = null;
    },

    // -- Public Methods -------------------------------------------------------

    /**
    Removes focus from this editor.

    @method blur
    @chainable
    **/
    blur: function () {
        if (this._rendered) {
            this._inputNode.blur();
        }

        return this;
    },

    /**
    Executes a given editor command.

    By default, the only supported commands are:

    - insertHTML
    - insertText

    See individual editor extensions for additionally supported commands

    @method command
    @param {String|Function} name Command name or function to execute.
    @param {Any} [args]* 0..n arguments to pass to the command
    @return {Any} Value of the specified command.
    **/
    command: function (name) {
        var command, ret,
            fn = name,
            args = Y.Array(arguments, 1, true);

        if ('string' === typeof fn) {
            command = this.commands[fn];

            if (command) {
                fn = command.commandFn;

                if (command.style) {
                    args.unshift(name);
                }
            }

            if ('string' === typeof fn) {
                fn = this[fn];
            }
        }

        this.focus();

        if ('function' === typeof fn) {
            ret = fn.apply(this, args);

            this._updateSelection({force: true});
        }

        return ret || this.query(name);
    },

    /**
    Focuses this editor.

    @method focus
    @chainable
    **/
    focus: function () {
        if (this._rendered) {
            this._inputNode.focus();
        }

        return this;
    },

    /**
    Gets the value of a specified editor command.

    See <https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla>
    for a list of possible commands.

    @method query
    @param {String} name Command name.
    @return {Boolean|String} Value of the specified command.
    **/
    query: function (name) {
        var command, ret,
            fn = name,
            args = Y.Array(arguments, 0, true);

        if ('string' === typeof fn) {
            command = this.commands[fn];

            if (command) {
                fn = command.queryFn;
            }

            fn = this[fn];
        }

        this.focus();

        if ('function' === typeof fn) {
            ret = fn.apply(this, args);
        }

        return ret;
    },

    /**
    Renders this editor into its container and appends the container to the
    document if necessary.

    @method render
    @chainable
    **/
    render: function () {
        var container  = this.get('container'),
            inputNode  = container.one(this.selectors.input);

        container.addClass(this.classNames.editor);

        if (!inputNode) {
            inputNode = container.appendChild('<div/>')
                                 .addClass(this.classNames.input);
        }

        var html = this.get('html'),
            text = this.get('text');

        if (html) {
            inputNode.setHTML(html);
        } else if (text) {
            inputNode.set('text', text);
        } else {
            inputNode.setHTML('<p><br></p>');
        }

        inputNode.set('contentEditable', true);

        this._inputNode = inputNode;
        this._rendered  = true;

        this.fire(EVT_RENDER);

        return this;
    },

    // -- Protected Methods ----------------------------------------------------

    /**
    Attaches editor events.

    @method _attachEvents
    @protected
    **/
    _attachEvents: function () {
        if (this._events) {
            return;
        }

        var container = this.get('container'),
            selectors = this.selectors;

        this._events = [
            container.delegate('blur',  this._onBlur,  selectors.input, this),
            container.delegate('copy',  this._onCopy,  selectors.input, this),
            container.delegate('cut',  this._onCut,  selectors.input, this),
            container.delegate('dblclick', this._onDblClick, selectors.input, this),
            container.delegate('focus', this._onFocus, selectors.input, this),
            container.delegate('paste', this._onPaste, selectors.input, this)
        ];
    },

    /**
    Detaches editor events.

    @method _detachEvents
    @protected
    **/
    _detachEvents: function () {
        if (this._events) {
            new Y.EventHandle(this._events).detach();
            this._events = null;
        }
    },

    /**
    Wrapper for native the native `execCommand()` that verifies that the command
    is valid in the current state

    @method _execCommand
    @param {String} name Command name.
    @param {Boolean|String} value Command value.
    @protected
    **/
    _execCommand: function (name, value) {
        if (!doc.queryCommandSupported(name) || !doc.queryCommandEnabled(name)) {
            Y.log('Command is not currently allowed: ' + name, 'warn', 'sm-editor');
            return;
        }

        doc.execCommand(name, false, value);
    },

    /**
    Getter for the `html` attribute.

    @method _getHTML
    @param {HTML} value Internal value.
    @return {HTML} HTML.
    @protected
    **/
    _getHTML: function (value) {
        return this._rendered ? this._inputNode.getHTML() : value;
    },

    /**
    Returns nodes containing any part of the given `range` matching the
    given `selector`

    @method _getNodes
    @param {Range} range
    @param {String} selector
    @return {NodeList}
    @protected
    **/
    _getNodes: function (range, selector) {
        var startNode, startOffset,
            testNode, nodes = [];

        range = range.clone().shrink();

        startNode = range.startNode();
        startOffset = range.startOffset();

        if (range.isCollapsed()) {
            var childNodes = startNode.get('childNodes');

            if (!EDOM.isTextNode(startNode) && childNodes.item(startOffset - 1)) {
                // the range is collapsed so it will never get traversed. grab
                // the exact node referenced by startNode/startOffset and work
                // backwards from there
                testNode = childNodes.item(startOffset - 1);
            } else {
                testNode = startNode;
            }
        } else {
            // traversal will include the startNode, so start off with the
            // startNodes parent
            testNode = startNode.get('parentNode');
        }

        while (testNode && testNode !== this._inputNode && this._inputNode.contains(testNode)) {
            if (testNode.test(selector)) {
                nodes.push(testNode);
            }

            testNode = testNode.get('parentNode');
        }

        range.traverse(function (node) {
           if (node.test(selector)) {
               nodes.push(node);
           }
        });

        return Y.all(nodes);
    },

    /**
    Getter for the `text` attribute.

    @method _getText
    @param {String} value Internal value.
    @return {String} Text.
    @protected
    **/
    _getText: function (value) {
        return this._rendered ? this._inputNode.get('text') : value;
    },

    /**
    Inserts the specified _html_ at the current selection point, deleting the
    current selection if there is one.

    @method _insertHTML
    @param {HTML|HTMLElement|Node} html HTML to insert, in the form of an HTML
        string, HTMLElement, or Node instance.
    @return {Node} Node instance representing the inserted HTML.
    @protected
    **/
    _insertHTML: function (html) {
        var node      = typeof html === 'string' ? Y.Node.create(html) : html,
            selection = this.selection,
            range     = selection.range();

        if (!range) {
            return;
        }

        // expanding the range before deleting contents makes sure
        // the entire node is deleted, if possible.
        range.expand({stopAt: this._inputNode});

        node = range.deleteContents().insertNode(node);

        range.collapse();

        selection.select(range);

        return node;
    },

    /**
    Inserts a `<span>` at the current selection point containing a preformatted
    tab character.

    @method _insertTab
    @protected
    **/
    _insertTab: function () {
        this._insertHTML('<span style="white-space:pre;">\t</span>');
    },

    /**
    Inserts the specified plain _text_ at the current selection point, deleting
    the current selection if there is one.

    @method _insertText
    @param {String} text Text to insert.
    @return {Node} Node instance representing the inserted text node.
    @protected
    **/
    _insertText: function (text) {
        // replace any newlines with spaces. browsers will convert
        // back to back newlines into paragraphs in the `formatBlock` command
        // which could cause nesting issues depending on where the text is
        // being inserted
        text = text.replace(/\n+/g, ' ');

        return this._insertHTML(doc.createTextNode(text));
    },


    /**
    No-op function for allowing default browser implementations.

    Use as the `commandFn` in a command config when the default browser
    behavior is desired. Allows for the command stack to execute and
    selection to be updated

    @method _noCommand
    @protected
    **/
    _noCommand: function () {
        // no-op
    },


    /**
    Wrapper for the native `queryCommandValue()` method

    @method _queryCommandValue
    @param {String} name Command name.
    @return {Boolean|String} Command value.
    @protected
    **/
    _queryCommandValue: function (name) {
        return doc.queryCommandSupported(name) ? doc.queryCommandValue(name) : null;
    },

    /**
    Setter for the `html` attribute.

    @method _setHTML
    @param {HTML} value HTML.
    @return {HTML} HTML.
    @protected
    **/
    _setHTML: function (value) {
        if (this._rendered) {
            this._inputNode.setHTML(value);
        }

        return value;
    },

    /**
    Setter for the `text` attribute.

    @method _setText
    @param {String} value Text.
    @return {String} Text.
    @protected
    **/
    _setText: function (value) {
        if (this._rendered) {
            this._inputNode.set('text', value);
        }

        return value;
    },

    /**
    Refreshes the editor's internal knowledge of the current document selection
    state and fires a `selectionChange` event if the selection has changed since
    it was last refreshed.

    @method _updateSelection
    @param {Object} [options] Options.
        @param {Boolean} [options.force=false] If `true`, the internal selection
            state will be updated regardless of if the selection changed.
        @param {Boolean} [options.silent=false] If `true`, the `selectionChange`
            event will be suppressed.
    @protected
    **/
    _updateSelection:  function (options) {
        var prevRange = this._selectedRange || null,
            newRange  = this.selection.range() || null,
            force     = options && options.force,
            silent    = options && options.silent;

        if (!force && (
                newRange === prevRange || (
                    prevRange &&
                    prevRange.isEquivalent(newRange) &&
                    prevRange.toHTML() === newRange.toHTML()
                )
            )
        ) {
            return;
        }

        this._selectedRange = newRange;

        // Only fire an event if options.silent is falsy and the new range is
        // either null or is entirely inside this editor.
        if (!silent && (!newRange || newRange.isInsideNode(this._inputNode))) {
            this.fire(EVT_SELECTION_CHANGE, {
                prevRange: prevRange,
                range    : newRange,
                selection: this.selection
            });
        }
    },

    // -- Protected Event Handlers ---------------------------------------------

    /**
    Handles `blur` events on the editor.

    @method _onBlur
    @protected
    **/
    _onBlur: function () {
        if (!this._rendered) {
            return;
        }

        clearInterval(this._selectionMonitor);

        this.fire(EVT_BLUR);
    },

    /**
    Handles `copy` events on the editor.

    @method _onCopy
    @param {EventFacade} e
    @protected
    **/
    _onCopy: function (e) {
        var clipboard = e._event.clipboardData || win.clipboardData,
            range = this.selection.range(),
            contents = range.cloneContents().getHTML();

        e.preventDefault();

        try {
            // IE doesn't support mime types
            clipboard.setData('text/html', contents);
            clipboard.setData('text/plain', contents);
        } catch (err) {
            clipboard.setData('text', contents);
        }
    },

    /**
    Handles `cut` events on the editor.

    @method _onCut
    @param {EventFacade} e
    @protected
    **/
    _onCut: function (e) {
        var clipboard = e._event.clipboardData || win.clipboardData,
            range = this.selection.range(),
            contents;

        // expand the range to prevent any empty nodes
        // being left after `extractContents()`
        range.expand({stopAt: this._inputNode});
        contents = range.extractContents().getHTML();

        e.preventDefault();

        this.selection.select(range);

        try {
            // IE doesn't support mime types
            clipboard.setData('text/html', contents);
            clipboard.setData('text/plain', contents);
        } catch (err) {
            clipboard.setData('text', contents);
        }
    },

    /**
    Handles `dblclick` events on the editor.

    @method _onDblClick
    @protected
    **/
    _onDblClick: function() {
        var range = this.selection.range();

        this.selection.select(range.shrink({trim: true}));
    },

    /**
    Handles `focus` events on the editor.

    @method _onFocus
    @protected
    **/
    _onFocus: function () {
        var self = this,
            selection = this.selection,
            range;

        if (!this._rendered) {
            return;
        }

        // restore the previously selected range, or create a new range
        if (!(range = this._selectedRange)) {
            var node = this._inputNode.get('firstChild') || this._inputNode;

            range = new Y.Range();
            range.selectNodeContents(node);
            range.collapse({toStart: true});
        }

        selection.select(range);

        this._updateSelection();

        clearInterval(this._selectionMonitor);

        this._selectionMonitor = setInterval(function () {
            self._updateSelection();
        }, 200);

        this.fire(EVT_FOCUS);
    },

    /**
    Handles `paste` events on the editor.

    @method _onPaste
    @param {EventFacade} e
    @protected
    **/
    _onPaste: function (e) {
        var clipboard = e._event.clipboardData || win.clipboardData,
            contents = clipboard.getData('text');

        e.preventDefault();

        // create a document-fragment with the pasted contents
        // then get the text content of the fragment. effectively
        // strips tags.
        contents = Y.Node.create(contents).get('text');

        this.command('insertText', contents);
    }
}, {
    ATTRS: {
        /**
        HTML content of this editor.

        @attribute {HTML} html
        @default ''
        **/
        html: {
            getter: '_getHTML',
            setter: '_setHTML',
            value : ''
        },

        /**
        Form field name to use for the hidden `<textarea>` that contains the raw
        output of the editor in the configured output format. This name will
        only be used if the output node doesn't already have a name when the
        editor is rendered.

        You may need to customize this if you plan to use the editor in a form
        that will be submitted to a server.

        @attribute {String} outputName
        @default 'editor'
        @initOnly
        **/
        outputName: {
            value    : 'editor',
            writeOnce: 'initOnly'
        },

        /**
        Text content of this editor, with no HTML.

        @attribute {String} text
        @default ''
        **/
        text: {
            getter: '_getText',
            setter: '_setText',
            value : ''
        }
    }
});

Y.namespace('Editor').Base = EditorBase;