Proyectos de Subversion Moodle

Rev

Rev 1 | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/*
 * @package    atto_link
 * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

/**
 * @module moodle-atto_link-button
 */

/**
 * Atto text editor link plugin.
 *
 * @namespace M.atto_link
 * @class button
 * @extends M.editor_atto.EditorPlugin
 */

var COMPONENTNAME = 'atto_link',
    CSS = {
        NEWWINDOW: 'atto_link_openinnewwindow',
        URLINPUT: 'atto_link_urlentry',
        URLTEXT: 'atto_link_urltext'
    },
    SELECTORS = {
        NEWWINDOW: '.atto_link_openinnewwindow',
        URLINPUT: '.atto_link_urlentry',
        URLTEXT: '.atto_link_urltext',
        SUBMIT: '.submit',
        LINKBROWSER: '.openlinkbrowser'
    },
    TEMPLATE = '' +
            '<form class="atto_form">' +
                '<div class="mb-1">' +
                '<label for="{{elementid}}_atto_link_urltext">{{get_string "texttodisplay" component}}</label>' +
                '<input class="form-control fullwidth {{CSS.URLTEXT}}" type="text" ' +
                'id="{{elementid}}_atto_link_urltext" size="32"/>' +
                '</div>' +
                '{{#if showFilepicker}}' +
                    '<label for="{{elementid}}_atto_link_urlentry">{{get_string "enterurl" component}}</label>' +
                    '<div class="input-group input-append w-100 mb-1">' +
                        '<input class="form-control url {{CSS.URLINPUT}}" type="url" ' +
                        'id="{{elementid}}_atto_link_urlentry"/>' +
                        '<span class="input-group-append">' +
                            '<button class="btn btn-secondary openlinkbrowser" type="button">' +
                            '{{get_string "browserepositories" component}}</button>' +
                        '</span>' +
                    '</div>' +
                '{{else}}' +
                    '<div class="mb-1">' +
                        '<label for="{{elementid}}_atto_link_urlentry">{{get_string "enterurl" component}}</label>' +
                        '<input class="form-control fullwidth url {{CSS.URLINPUT}}" type="url" ' +
                        'id="{{elementid}}_atto_link_urlentry" size="32"/>' +
                    '</div>' +
                '{{/if}}' +
                '<div class="form-check">' +
                    '<input type="checkbox" class="form-check-input newwindow {{CSS.NEWWINDOW}}" ' +
                    'id="{{elementid}}_{{CSS.NEWWINDOW}}"/>' +
                    '<label class="form-check-label" for="{{elementid}}_{{CSS.NEWWINDOW}}">' +
                    '{{get_string "openinnewwindow" component}}' +
                    '</label>' +
                '</div>' +
                '<div class="mdl-align">' +
                    '<br/>' +
                    '<button type="submit" class="btn btn-secondary submit">{{get_string "createlink" component}}</button>' +
                '</div>' +
            '</form>';
Y.namespace('M.atto_link').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {

    /**
     * A reference to the current selection at the time that the dialogue
     * was opened.
     *
     * @property _currentSelection
     * @type Range
     * @private
     */
    _currentSelection: null,

    /**
     * A reference to the dialogue content.
     *
     * @property _content
     * @type Node
     * @private
     */
    _content: null,

    /**
     * Text to display has value or not.
     * @property _hasTextToDisplay
     * @type Boolean
     * @private
     */
    _hasTextToDisplay: false,

    /**
     * User has selected plain text or not.
     * @property _hasPlainTextSelected
     * @type Boolean
     * @private
     */
    _hasPlainTextSelected: false,

    initializer: function() {
        // Add the link button first.
        this.addButton({
            icon: 'e/insert_edit_link',
            keys: '75',
            callback: this._displayDialogue,
            tags: 'a',
            tagMatchRequiresAll: false
        });

        // And then the unlink button.
        this.addButton({
            buttonName: 'unlink',
            callback: this._unlink,
            icon: 'e/remove_link',
            title: 'unlink',

            // Watch the following tags and add/remove highlighting as appropriate:
            tags: 'a',
            tagMatchRequiresAll: false
        });
    },

    /**
     * Display the link editor.
     *
     * @method _displayDialogue
     * @private
     */
    _displayDialogue: function() {
        // Store the current selection.
        this._currentSelection = this.get('host').getSelection();
        if (this._currentSelection === false) {
            return;
        }

        var dialogue = this.getDialogue({
            headerContent: M.util.get_string('createlink', COMPONENTNAME),
            width: 'auto',
            focusAfterHide: true,
            focusOnShowSelector: SELECTORS.URLINPUT
        });

        // Set the dialogue content, and then show the dialogue.
        dialogue.set('bodyContent', this._getDialogueContent());

        // Resolve anchors in the selected text.
        this._resolveAnchors();
        dialogue.show();
    },

    /**
     * If there is selected text and it is part of an anchor link,
     * extract the url (and target) from the link (and set them in the form).
     *
     * @method _resolveAnchors
     * @private
     */
    _resolveAnchors: function() {
        // Find the first anchor tag in the selection.
        var selectednode = this.get('host').getSelectionParentNode(),
            anchornodes,
            anchornode,
            url,
            target,
            textToDisplay,
            title;

        // Note this is a document fragment and YUI doesn't like them.
        if (!selectednode) {
            return;
        }

        anchornodes = this._findSelectedAnchors(Y.one(selectednode));
        if (anchornodes.length > 0) {
            anchornode = anchornodes[0];
            this._currentSelection = this.get('host').getSelectionFromNode(anchornode);
            url = anchornode.getAttribute('href');
            target = anchornode.getAttribute('target');
            textToDisplay = anchornode.get('innerText');
            title = anchornode.getAttribute('title');
            if (url !== '') {
                this._content.one(SELECTORS.URLINPUT).setAttribute('value', url);
            }
            if (textToDisplay !== '') {
                this._content.one(SELECTORS.URLTEXT).set('value', textToDisplay);
            } else if (title !== '') {
                this._content.one(SELECTORS.URLTEXT).set('value', title);
            }
            if (target === '_blank') {
                this._content.one(SELECTORS.NEWWINDOW).setAttribute('checked', 'checked');
            } else {
                this._content.one(SELECTORS.NEWWINDOW).removeAttribute('checked');
            }
        } else {
            // User is selecting some text before clicking on the Link button.
            textToDisplay = this._getTextSelection();
            if (textToDisplay !== '') {
                this._hasTextToDisplay = true;
                this._hasPlainTextSelected = true;
                this._content.one(SELECTORS.URLTEXT).set('value', textToDisplay);
            }
        }
    },

    /**
     * Update the dialogue after a link was selected in the File Picker.
     *
     * @method _filepickerCallback
     * @param {object} params The parameters provided by the filepicker
     * containing information about the link.
     * @private
     */
    _filepickerCallback: function(params) {
        this.getDialogue()
                .set('focusAfterHide', null)
                .hide();

        if (params.url !== '') {
            // Add the link.
            this._setLinkOnSelection(params.url);

            // And mark the text area as updated.
            this.markUpdated();
        }
    },

    /**
     * The link was inserted, so make changes to the editor source.
     *
     * @method _setLink
     * @param {EventFacade} e
     * @private
     */
    _setLink: function(e) {
        var input,
            value;

        e.preventDefault();
        this.getDialogue({
            focusAfterHide: null
        }).hide();

        input = this._content.one(SELECTORS.URLINPUT);

        value = input.get('value');
        if (value !== '') {

            // We add a prefix if it is not already prefixed.
            value = value.trim();
            var expr = new RegExp(/^[a-zA-Z]*\.*\/|^#|^[a-zA-Z]*:/);
            if (!expr.test(value)) {
                value = 'http://' + value;
            }

            // Add the link.
            this._setLinkOnSelection(value);

            this.markUpdated();
        }
    },

    /**
     * Final step setting the anchor on the selection.
     *
     * @private
     * @method _setLinkOnSelection
     * @param  {String} url URL the link will point to.
     * @return {Node} The added Node.
     */
    _setLinkOnSelection: function(url) {
        var host = this.get('host'),
            link,
            selectednode,
            target,
            anchornodes,
            isUpdating,
            urlText,
            textToDisplay;

        this.editor.focus();
        host.setSelection(this._currentSelection);
        isUpdating = !this._currentSelection[0].collapsed;
        urlText = this._content.one(SELECTORS.URLTEXT);
        textToDisplay = urlText.get('value').replace(/(<([^>]+)>)/gi, "").trim();
        if (textToDisplay === '') {
            textToDisplay = url;
        }

        if (!isUpdating) {
            // Firefox cannot add links when the selection is empty so we will add it manually.
            link = Y.Node.create('<a>' + textToDisplay + '</a>');
            link.setAttribute('href', url);

            // Add the node and select it to replicate the behaviour of execCommand.
            selectednode = host.insertContentAtFocusPoint(link.get('outerHTML'));
            host.setSelection(host.getSelectionFromNode(selectednode));
        } else {
            if (Y.UA.gecko > 0) {
                // For Firefox / Gecko we need to wrap the selection in a span so we can surround it with an anchor.
                // This relates to https://bugzilla.mozilla.org/show_bug.cgi?id=1906559.
                var originalSelection = document.getSelection();
                var wrapper = document.createElement('span');
                wrapper.setAttribute('data-wrapper', '');
                wrapper.style.display = 'inline';

                var i;
                for (i = 0; i < originalSelection.rangeCount; i++) {
                    originalSelection.getRangeAt(i).surroundContents(wrapper);
                }
                host.setSelection(host.getSelectionFromNode(Y.one(wrapper)));

                document.execCommand('unlink', false, null);
                document.execCommand('createLink', false, url);

                var anchorNode = wrapper.parentNode;
                wrapper.children.forEach(function(child) {
                    anchorNode.appendChild(child);
                });
                wrapper.remove();

            } else {
                document.execCommand('unlink', false, null);
                document.execCommand('createLink', false, url);
            }

            // Now set the target.
            selectednode = host.getSelectionParentNode();
        }

        // Note this is a document fragment and YUI doesn't like them.
        if (!selectednode) {
            return;
        }

        anchornodes = this._findSelectedAnchors(Y.one(selectednode));
        // Add new window attributes if requested.
        Y.Array.each(anchornodes, function(anchornode) {
            target = this._content.one(SELECTORS.NEWWINDOW);
            if (target.get('checked')) {
                anchornode.setAttribute('target', '_blank');
            } else {
                anchornode.removeAttribute('target');
            }
            if (isUpdating && textToDisplay) {
                // The 'createLink' command do not allow to set the custom text to display. So we need to do it here.
                if (this._hasPlainTextSelected) {
                    // Only replace the innerText if the user has not selected any element or just the plain text.
                    anchornode.set('innerText', textToDisplay);
                } else {
                    // The user has selected another element to add the hyperlink, like an image.
                    // We should add the title attribute instead of replacing the innerText of the hyperlink.
                    anchornode.setAttribute('title', textToDisplay);
                }
            }
        }, this);

        return selectednode;
    },

    /**
     * Look up and down for the nearest anchor tags that are least partly contained in the selection.
     *
     * @method _findSelectedAnchors
     * @param {Node} node The node to search under for the selected anchor.
     * @return {Node|Boolean} The Node, or false if not found.
     * @private
     */
    _findSelectedAnchors: function(node) {
        var tagname = node.get('tagName'),
            hit, hits;

        // Direct hit.
        if (tagname && tagname.toLowerCase() === 'a') {
            return [node];
        }

        // Search down but check that each node is part of the selection.
        hits = [];
        node.all('a').each(function(n) {
            if (!hit && this.get('host').selectionContainsNode(n)) {
                hits.push(n);
            }
        }, this);
        if (hits.length > 0) {
            return hits;
        }
        // Search up.
        hit = node.ancestor('a');
        if (hit) {
            return [hit];
        }
        return [];
    },

    /**
     * Generates the content of the dialogue.
     *
     * @method _getDialogueContent
     * @return {Node} Node containing the dialogue content
     * @private
     */
    _getDialogueContent: function() {
        var canShowFilepicker = this.get('host').canShowFilepicker('link'),
            template = Y.Handlebars.compile(TEMPLATE);

        this._content = Y.Node.create(template({
            showFilepicker: canShowFilepicker,
            component: COMPONENTNAME,
            CSS: CSS
        }));

        this._content.one(SELECTORS.URLINPUT).on('keyup', this._updateTextToDisplay, this);
        this._content.one(SELECTORS.URLINPUT).on('change', this._updateTextToDisplay, this);
        this._content.one(SELECTORS.URLTEXT).on('keyup', this._setTextToDisplayState, this);
        this._content.one(SELECTORS.SUBMIT).on('click', this._setLink, this);
        if (canShowFilepicker) {
            this._content.one(SELECTORS.LINKBROWSER).on('click', function(e) {
                e.preventDefault();
                this.get('host').showFilepicker('link', this._filepickerCallback, this);
            }, this);
        }

        return this._content;
    },

    /**
     * Unlinks the current selection.
     * If the selection is empty (e.g. the cursor is placed within a link),
     * then the whole link is unlinked.
     *
     * @method _unlink
     * @private
     */
    _unlink: function() {
        var host = this.get('host'),
            range = host.getSelection();

        if (range && range.length) {
            if (range[0].startOffset === range[0].endOffset) {
                // The cursor was placed in the editor but there was no selection - select the whole parent.
                var nodes = host.getSelectedNodes();
                if (nodes) {
                    // We need to unlink each anchor individually - we cannot select a range because it may only consist of a
                    // fragment of an anchor. Selecting the parent would be dangerous because it may contain other links which
                    // would then be unlinked too.
                    nodes.each(function(node) {
                        // We need to select the whole anchor node for this to work in some browsers.
                        // We only need to search up because getSelectedNodes returns all Nodes in the selection.
                        var anchor = node.ancestor('a', true);
                        if (anchor) {
                            // Set the selection to the whole of the first anchor.
                            host.setSelection(host.getSelectionFromNode(anchor));

                            // Call the browser unlink.
                            document.execCommand('unlink', false, null);
                        }
                    }, this);

                    // And mark the text area as updated.
                    this.markUpdated();
                }
            } else {
                // Call the browser unlink.
                document.execCommand('unlink', false, null);

                // And mark the text area as updated.
                this.markUpdated();
            }
        }
    },

    /**
     * Set the current text to display state.
     *
     * @method _setTextToDisplayState
     * @private
     */
    _setTextToDisplayState: function() {
        var urlText,
            urlTextVal;
        urlText = this._content.one(SELECTORS.URLTEXT);
        urlTextVal = urlText.get('value');
        if (urlTextVal !== '') {
            this._hasTextToDisplay = true;
        } else {
            this._hasTextToDisplay = false;
        }
    },

    /**
     * Update the text to display if the user does not provide the custom text.
     *
     * @method _updateTextToDisplay
     * @private
     */
    _updateTextToDisplay: function() {
        var urlEntry,
            urlText,
            urlEntryVal;
        urlEntry = this._content.one(SELECTORS.URLINPUT);
        urlText = this._content.one(SELECTORS.URLTEXT);
        urlEntryVal = urlEntry.get('value');
        if (!this._hasTextToDisplay) {
            urlText.set('value', urlEntryVal);
        }
    },

    /**
     * Get only the selected text.
     * In some cases, window.getSelection() is not run as expected. We should only get the text value
     * For ex: <img src="" alt="XYZ">Some text here
     *          window.getSelection() will return XYZSome text here
     *
     * @returns {string} Selected text
     * @private
     */
    _getTextSelection: function() {
        var selText = '';
        var sel = window.getSelection();
        var rangeCount = sel.rangeCount;
        if (rangeCount) {
            var rangeTexts = [];
            for (var i = 0; i < rangeCount; ++i) {
                rangeTexts.push('' + sel.getRangeAt(i));
            }
            selText = rangeTexts.join('');
        }
        return selText;
    }
});