API Docs for: 3.11.0-git
Show:

File: src/sm-editor/js/extensions/editor-block.js

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

/**
Provides the `Editor.Block` extension.

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

/**
Extension for `Editor.Base` that handles block element formatting

Provides support for the following commands:

- formatBlock
- heading
- insertParagraph

@class Editor.Block
@extends Base
@extensionfor Editor.Base
**/

(function () {

var EDOM = Y.Editor.DOM;

var EditorBlock = Y.Base.create('editorBlock', Y.Base, [], {
    // -- Public Properties ----------------------------------------------------

    /**
    Hash of block commands supported by this editor.

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

    @property {Object} blockCommands
        @param {Function|String} commandFn
        @param {Function|String} [queryFn]
    **/
    blockCommands: {
        formatBlock: {
            commandFn: '_formatBlock',
            queryFn: '_queryBlockCommand'
        },

        heading: {
            commandFn: '_formatBlock',
            queryFn: '_queryBlockCommand'
        },

        insertBreak: {
            commandFn: '_noCommand'
        },

        insertParagraph: {
            commandFn: '_insertReturn',
            queryFn: '_queryBlockCommand'
        }
    },


    /**
    Key commands related to block functionality.

    @property {Object} styleKeyCommands
    **/
    blockKeyCommands: {
        'alt+c':       'justifyCenter',
        'alt+f':       'justifyFull',
        'alt+l':       'justifyLeft',
        'alt+r':       'justifyRight',
        'enter':       'insertParagraph',
        // ctrl+enter for safari, shift+enter for sane browsers. safe to have
        // both declarations here because they pass through to default behavior
        'ctrl+enter':  {fn: 'insertBreak', allowDefault: true, async: true},
        'shift+enter': {fn: 'insertBreak', allowDefault: true, async: true}
    },


    /**
    HTML tags supported by this editor. Unsupported tags will be treated
    as text

    @property {String} blockTags
    **/
    blockTags: 'div, p, h1, h2, h3, h4, h5',


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

    initializer: function () {
        this.commands = Y.merge(this.commands, this.blockCommands);

        if (this.supportedTags) {
            this.supportedTags += ',' + this.blockTags;
        } else {
            this.supportedTags = this.blockTags;
        }

        if (this.keyCommands) {
            this.keyCommands = Y.merge(this.keyCommands, this.blockKeyCommands);
        }

        this._attachBlockEvents();
    },


    destructor: function () {
        this._detachBlockEvents();
    },


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

    /**
    Attaches block events.

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

        this._blockEvents = [
            this.on('selectionChange', this._blockOnSelectionChange, this)
        ];
    },


    /**
    Detaches block events.

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


    /**
    Replaces block elements containing the current selection with elements
    of the given `tag`

    @method _formatBlock
    @param {String} tag The new block element tag
    @protected
    **/
    _formatBlock: function (tag) {
        tag = tag.replace(/[<>]/g, '');

        if (-1 < this.blockTags.indexOf(tag)) {
            tag = '<' + tag + '>';

            var selection = this.selection,
                range = selection.range(),
                nodes = [];

            this._getNodes(range, this.blockTags).each(function (node) {
                var newNode = Y.Node.create(tag);

                newNode.insert(node.get('childNodes'));

                EDOM.copyStyles(node, newNode, this.supportedStyles, {
                    explicit: true,
                    overwrite: false
                });

                node.replace(newNode).remove(true);

                nodes.push(newNode);
            }, this);

            // hack until bookmarks are implemented to preserve range
            range.startNode(nodes[0]);
            range.endNode(nodes[nodes.length]);

            range.shrink().collapse({toStart: true});

            selection.select(range);
        }
    },


    /**
    Inserts a `return` at the current selection point.

    Any content contained by the range is deleted, resulting in a collapsed range.

    If the range is at the start of a block, a duplicate, empty block is
    inserted as the previous sibling of current block. The range is positioned
    at the beginning of the new block

    If the range is at the end of a block, a new `<p>` element is created as
    the next sibling of the current block. The range is positioned at the start
    of the new block.

    If the range is in the middle of a block, the block will be split at the
    current position. The range will be positioned at the beginning of the new
    block.

    @method _insertReturn
    @protected
    **/
    _insertReturn: function () {
        var selection = this.selection,
            range = selection.range().shrink(),
            block, startRange, endRange;

        range.deleteContents();

        // the range will be collapsed after deleteContents, so
        // there will only ever be one 'block'
        block = this._getNodes(range, this.blockTags).item(0);

        // when hitting enter in an `empty` block, collapse the
        // the range to the end of the block to force the new block
        // to be inserted after
        if ('' === block.get('text')) {
            range.selectNodeContents(block).collapse();
        }

        range.expand({stopAt: block});
        startRange = range.clone().selectNodeContents(block).collapse({toStart: true});
        endRange = range.clone().selectNodeContents(block).collapse();

        if (0 === range.compare(startRange, {myPoint: 'start', otherPoint: 'start'})) {
            // the range is collapsed at the start of the block, insert
            // a clone of the block before the current block
            block = block.insert(block.cloneNode(), 'before').previous();
        } else if (0 === range.compare(endRange, {myPoint: 'end', otherPoint: 'end'})) {
            // the range is collapsed at the end of the block, start a new
            // paragraph after the current block
            block = block.insert('<p></p>', 'after').next();
        } else {
            // somewhere in the middle of a block node, split it
            block = this._splitRange(range, this.blockTags);
        }

        // in order to be able to place the cursor inside an element
        // in webkit we need to insert a br
        if (EDOM.isEmptyNode(block)) {
            block.setHTML('<br>');
        }

        range.selectNodeContents(block).collapse({toStart: true});

        selection.select(range);
    },


    /**
    Default query function for block elements

    @method _queryBlockCommand
    @return {NodeList} A nodelist of the block nodes containing the range
    @protected
     */
    _queryBlockCommand: function() {
        return this._getNodes(this.selection.range(), this.blockTags);
    },


    /**
    Splits elements after the given range until a node matching the
    given `selector` is reached.

    @method _splitRange
    @param {Range} range
    @param {String} selector
    @return {Node} The node created after splitting
    @protected
    **/
    _splitRange: function (range, selector) {
        var endNode, endOffset;

        endNode = range.endNode();
        endOffset = range.endOffset();

        while (!endNode.test(selector)) {
            endOffset = EDOM.split(endNode, endOffset);
            endNode = endOffset.get('parentNode');
        }

        if (this._inputNode !== endNode) {
            endOffset = EDOM.split(endNode, endOffset);
            endNode = endOffset.get('parentNode');
        }

        if (!endOffset._node) {
            endOffset = endNode.get('childNodes').item(endOffset);
        }

        return endOffset;
    },


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

    /**
    Event handler for the selection `change` event

    Creates a default `block` if none exists for the current selection

    @method _blockOnSelectionChange
    @param {EventFacade} e
    @protected
    **/
    _blockOnSelectionChange: function (e) {
        var range = e.range,
            startNode;

        if (!range || !range.isCollapsed()) {
            return;
        }

        startNode = range.startNode();

        if (this._inputNode === startNode.ancestor(this.blockTags, true)) {
            Y.Editor.Base.prototype._execCommand.call(this, 'formatBlock', '<p>');
        }
    }
});

Y.namespace('Editor').Block = EditorBlock;

}());