wikimedia/mediawiki-extensions-Translate

View on GitHub
resources/js/ext.translate.editor.js

Summary

Maintainability
F
6 days
Test Coverage
/* global autosize */

( function () {
    'use strict';

    /**
     * Dictionary of classes that will be used by different types of notices
     * TODO: Should probably review and rename these classes in the future to
     * be more unique to the translate extension? Some themes use warning,
     * error classes to style elements, and we do take help from these.
     */
    var noticeTypes = {
        warning: 'warning',
        error: 'error',
        translateFail: 'translation-saving',
        diff: 'diff',
        fuzzy: 'fuzzy',
        getAllClasses: function () {
            var classes = [];

            for ( var prop in this ) {
                if ( typeof this[ prop ] === 'string' ) {
                    classes.push( this[ prop ] );
                }
            }

            return classes;
        }
    };

    /**
     * TranslateEditor Plugin
     * Prepare the translation editor UI for a translation unit (message).
     * This is mainly used with the messagetable plugin,
     * but it is independent of messagetable.
     * Example usage:
     *
     *     $( 'div.messageRow' ).translateeditor( {
     *         message: messageObject // Mandatory message object
     *     } );
     *
     * Assumptions: The jquery element to which translateeditor is applied will
     * internally contain the editor's generated UI. So it is going to have the same width
     * and inherited properies of the container.
     * The container can mark the message item with class 'message'. This is not
     * mandatory, but if found, when the editor is opened, the message item will be hidden
     * and the editor will appear as if the message is replaced by the editor.
     * See the UI of Translate messagetable for a demo.
     *
     * @private
     * @param {HTMLElement} element
     * @param {Object} options
     * @param {Function} [options.beforeSave] Callback to call when translation is going to be saved.
     * @param {Function} [options.onReady] Callback to call when the editor is ready.
     * @param {Function} [options.onSave] Callback to call when translation has been saved.
     * @param {Function} [options.onSkip] Callback to call when a message is skipped.
     * @param {Object} options.message Object as returned by messagecollection api.
     * @param {mw.translate.TranslationApiStorage} [options.storage]
     */
    function TranslateEditor( element, options ) {
        this.$editTrigger = $( element );
        this.$editor = null;
        this.options = options;
        this.message = this.options.message;
        this.$messageItem = this.$editTrigger.find( '.message' );
        this.shown = false;
        this.dirty = false;
        this.saving = false;
        this.expanded = false;
        this.listen();
        this.storage = this.options.storage || new mw.translate.TranslationApiStorage();
        this.canDelete = mw.translate.canDelete();
        this.editFontClass = 'mw-editfont-' + mw.user.options.get( 'editfont' );
        this.delayValidation = delayer();
        this.validating = null;
    }

    TranslateEditor.prototype = {

        /**
         * Initialize the plugin
         *
         * @internal
         */
        init: function () {
            // In case we have already created the editor earlier,
            // don't add a new one. The existing one may have unsaved
            // changes.
            if ( this.$editor ) {
                return;
            }

            this.render();
            // onReady callback
            if ( this.options.onReady ) {
                this.options.onReady.call( this );
            }
        },

        /**
         * Render the editor UI
         *
         * @private
         */
        render: function () {
            this.$editor = $( '<div>' )
                .addClass( 'row tux-message-editor hide' )
                .append(
                    this.prepareEditorColumn(),
                    this.prepareInfoColumn()
                );

            this.expanded = false;
            this.$editTrigger.append( this.$editor );

            if ( this.message.properties && this.message.properties.status === 'fuzzy' ) {
                this.addNotice(
                    mw.message( 'tux-editor-outdated-notice' ).escaped(),
                    noticeTypes.fuzzy
                );
            }

            this.showTranslationHelpers();
        },

        /**
         * Mark the message as unsaved because of edits, can be resumed later
         *
         * @private
         * @param {string} [highlightClass] Class for background highlighting
         */
        markUnsaved: function ( highlightClass ) {
            var $tuxListStatus = this.$editTrigger.find( '.tux-list-status' );

            highlightClass = highlightClass || 'tux-highlight';

            $tuxListStatus.children( '.tux-status-unsaved' ).remove();
            $tuxListStatus.children().addClass( 'hide' );
            // `highlightClass` documented above
            // eslint-disable-next-line mediawiki/class-doc
            $( '<span>' )
                .addClass( 'tux-status-unsaved ' + highlightClass )
                .text( mw.msg( 'tux-status-unsaved' ) )
                .appendTo( $tuxListStatus );
        },

        /**
         * Mark the message as unsaved because of saving failure.
         *
         * @private
         */
        markUnsavedFailure: function () {
            this.markUnsaved( 'tux-notice' );
        },

        /**
         * Mark the message as no longer unsaved
         *
         * @internal
         */
        markUnunsaved: function () {
            var $tuxListStatus = this.$editTrigger.find( '.tux-list-status' );

            $tuxListStatus.children( '.tux-status-unsaved' ).remove();
            $tuxListStatus.children().removeClass( 'hide' );

            this.dirty = false;
            mw.translate.dirty = false;
        },

        /**
         * Mark the message as being saved
         *
         * @private
         */
        markSaving: function () {
            var $tuxListStatus = this.$editTrigger.find( '.tux-list-status' );

            // Disable the save button
            this.$editor.find( '.tux-editor-save-button' )
                .prop( 'disabled', true );

            // Add a "Saving" indicator
            $tuxListStatus.empty();
            $( '<span>' )
                .addClass( 'tux-status-unsaved' )
                .text( mw.msg( 'tux-status-saving' ) )
                .appendTo( $tuxListStatus );
        },

        /**
         * Mark the message as translated and successfully saved.
         *
         * @private
         */
        markTranslated: function () {
            this.$editTrigger.find( '.tux-list-status' )
                .empty()
                .append( $( '<span>' )
                    .addClass( 'tux-status-translated' )
                    .text( mw.msg( 'tux-status-translated' ) )
                );

            this.$messageItem
                .removeClass( 'untranslated translated fuzzy proofread' )
                .addClass( 'translated' );

            this.dirty = false;

            if ( this.message.properties ) {
                $( '.tux-action-bar .tux-statsbar' ).trigger(
                    'change',
                    [ 'translated', this.message.properties.status ]
                );

                this.message.properties.status = 'translated';
                // TODO: Update any other statsbar for the same group in the page.
            }
        },

        /**
         * Save the translation
         *
         * @private
         */
        save: function () {
            var translateEditor = this;

            mw.hook( 'mw.translate.editor.beforeSubmit' ).fire( translateEditor.$editor );
            var translation = translateEditor.$editor.find( '.tux-textarea-translation' ).val();
            var editSummary = translateEditor.$editor.find( '.tux-input-editsummary' ).val() || '';

            translateEditor.saving = true;

            // beforeSave callback
            if ( translateEditor.options.beforeSave ) {
                translateEditor.options.beforeSave( translation );
            }

            // For responsiveness and efficiency,
            // immediately move to the next message.
            translateEditor.next();

            // Now the message definitely has a history,
            // so make sure the history menu item is shown
            translateEditor.$editor.find( '.message-tools-history' )
                .removeClass( 'hide' );

            // Show the delete menu item if the user can delete
            if ( this.canDelete ) {
                translateEditor.$editor.find( '.message-tools-delete' )
                    .removeClass( 'hide' );
            }

            // Hide translation related to saving failure before saving again.
            translateEditor.removeNotices( noticeTypes.translateFail );
            this.storage.save(
                translateEditor.message.title,
                translation,
                editSummary
            ).done( function ( response, xhr ) {
                var editResp = response.edit;
                if ( editResp.result === 'Success' ) {
                    translateEditor.message.translation = translation;
                    translateEditor.onSaveSuccess();
                } else {
                    translateEditor.onSaveFail( [ mw.msg( 'tux-save-unknown-error' ) ] );
                    mw.log( response, xhr );
                }
            } ).fail( function ( errorCode, response ) {
                if ( errorCode === 'http' || errorCode === 'ok-but-empty' ) {
                    var api = new mw.Api();
                    translateEditor.displayNotices(
                        api.getErrorMessage( errorCode ),
                        noticeTypes.error
                    );
                    return;
                }

                var errors = [];
                for ( var i = 0; i < response.errors.length; i++ ) {
                    var error = response.errors[ i ];
                    if ( error.code === 'assertuserfailed' ) {
                        // eslint-disable-next-line no-alert
                        alert( mw.msg( 'tux-session-expired' ) );
                        break;
                    } else if ( error.code === 'translate-validation-failed' ) {
                        // Cancel the translation check API call to avoid extra
                        // notices from appearing.
                        if ( translateEditor.validating ) {
                            translateEditor.validating.abort();
                        } else {
                            // Cancel the translation check API call that might be made
                            // in the future.
                            translateEditor.delayValidation( false );
                        }

                        translateEditor.removeNotices( [ noticeTypes.error, noticeTypes.warning ] );

                        if ( error.data && error.data.validation ) {
                            translateEditor.displayNotices(
                                error.data.validation.warnings,
                                noticeTypes.warning
                            );
                            translateEditor.displayNotices(
                                error.data.validation.errors,
                                noticeTypes.error
                            );
                        }
                    }

                    errors.push( error.html );
                }

                // This is placed at the bottom to ensure that the save error appears at the
                // top of the notices
                translateEditor.onSaveFail(
                    errors.length ? errors : [ mw.msg( 'tux-save-unknown-error' ) ]
                );

                // Display all the notices whenever an error occurs.
                translateEditor.showMoreNotices();
            } );
        },

        /**
         * Success handler for the translation saving.
         *
         * @private
         */
        onSaveSuccess: function () {
            this.markTranslated();
            this.$editTrigger.find( '.tux-list-translation' )
                .text( this.message.translation );
            this.saving = false;

            // remove notices if any.
            this.removeNotices( noticeTypes.getAllClasses() );

            this.$editor.find( '.tux-notice' ).empty();
            this.$editor.find( '.tux-more-notices' )
                .addClass( 'hide' )
                .empty();

            $( '.tux-editor-clear-translated' )
                .removeClass( 'hide' )
                .prop( 'disabled', false );

            this.$editor.find( '.tux-input-editsummary' )
                .val( '' )
                .prop( 'disabled', true );

            // Save callback
            if ( this.options.onSave ) {
                this.options.onSave( this.message.translation );
            }

            mw.translate.dirty = false;
            mw.hook( 'mw.translate.editor.afterSubmit' ).fire( this.$editor );

            if ( mw.track ) {
                mw.track( 'ext.translate.event.translation', this.message );
            }
        },

        /**
         * Marks that there was a problem saving a translation.
         *
         * @private
         * @param {string[]} errors Array of HTML notices to display.
         */
        onSaveFail: function ( errors ) {
            var $error;
            if ( errors.length === 1 ) {
                $error = $( $.parseHTML( errors[ 0 ] ) );
            } else {
                var $errorList = $( '<ul>' );
                for ( var i = 0; i < errors.length; i++ ) {
                    $errorList.append( $( '<li>' ).html( errors[ i ] ) );
                }
                $error = $errorList;
            }

            this.addNotice(
                mw.message( 'tux-editor-save-failed', $error, errors.length ).parse(),
                noticeTypes.translateFail
            );
            this.saving = false;
            this.markUnsavedFailure();

            // Enable the save button again
            this.$editor.find( '.tux-editor-save-button' ).prop( 'disabled', false );
        },

        /**
         * Skip the current message. Record it to mark as hard.
         *
         * @private
         */
        skip: function () {
            // @TODO devise good algorithm for identifying hard to translate messages
        },

        /**
         * Jump to the next translation editor row.
         *
         * @private
         */
        next: function () {
            var $next = this.$editTrigger.next( '.tux-message' );

            // Determine the next message to show. The immediate next one maybe hidden
            // for example in case of filtering
            while ( $next.length && $next.hasClass( 'hide' ) ) {
                $next = $next.next( '.tux-message' );
            }

            // If this is the last message, just hide it
            if ( !$next.length ) {
                this.hide();

                return;
            }

            $next.data( 'translateeditor' ).show();

            // Scroll the page a little bit up, slowly.
            if ( $( document ).height() -
                ( document.documentElement.clientHeight + window.scrollY ) > 0
            ) {
                var scrollTop = window.scrollY + $next.get( 0 ).getBoundingClientRect().top - 85;
                window.scrollTo( {
                    top: scrollTop,
                    left: 0,
                    behavior: 'smooth'
                } );
            }
        },

        /**
         * Creates a menu element for the message tools.
         *
         * @private
         * @param {string} className Used as the element's CSS class
         * @param {Object} query Used as the query in the mw.Uri object
         * @param {string} message The message of the label of the menu item
         * @return {jQuery} The new menu item element
         */
        createMessageToolsItem: function ( className, query, message ) {
            var uri = new mw.Uri();

            uri.path = mw.config.get( 'wgScript' );
            uri.query = query;

            return $( '<li>' )
                .addClass( className )
                .append( $( '<a>' )
                    .attr( {
                        href: uri.toString(),
                        target: '_blank'
                    } )
                    .text( mw.msg( message ) )
                );
        },

        /**
         * Creates an element with a dropdown menu including
         * tools for the translators.
         *
         * @private
         * @return {jQuery} The new message tools menu element
         */
        createMessageTools: function () {
            var $editItem = this.createMessageToolsItem(
                'message-tools-edit',
                {
                    title: this.message.title,
                    action: 'edit'
                },
                'tux-editor-message-tools-show-editor'
            );

            if ( !mw.translate.canTranslate() ) {
                $editItem.addClass( 'hide' );
            }

            var $historyItem = this.createMessageToolsItem(
                'message-tools-history',
                {
                    title: this.message.title,
                    action: 'history'
                },
                'tux-editor-message-tools-history'
            );

            var $deleteItem = this.createMessageToolsItem(
                'message-tools-delete',
                {
                    title: this.message.title,
                    action: 'delete'
                },
                'tux-editor-message-tools-delete'
            );

            // Hide these links if the translation doesn't actually exist.
            // They will be shown when a translation will be created.
            if ( this.message.translation === null ) {
                $historyItem.addClass( 'hide' );
                $deleteItem.addClass( 'hide' );
            } else if ( !this.canDelete ) {
                $deleteItem.addClass( 'hide' );
            }

            // A link to Special:Translations,
            // with translations of this message to other languages
            var $translationsItem = this.createMessageToolsItem(
                'message-tools-translations',
                {
                    title: 'Special:Translations',
                    message: this.message.title
                },
                'tux-editor-message-tools-translations'
            );

            var $linkToThisItem = this.createMessageToolsItem(
                'message-tools-linktothis',
                {
                    title: 'Special:Translate',
                    showMessage: this.message.key,
                    group: this.message.primaryGroup,
                    language: this.message.targetLanguage
                },
                'tux-editor-message-tools-linktothis'
            );

            return $( '<ul>' )
                .addClass( 'tux-dropdown-menu tux-message-tools-menu hide' )
                .append( $editItem, $historyItem, $deleteItem, $translationsItem, $linkToThisItem );
        },

        /**
         * @private
         * @return {jQuery}
         */
        prepareEditorColumn: function () {
            var translateEditor = this,
                $discardChangesButton = $( [] ),
                $saveButton = $( [] ),
                $messageTools = translateEditor.createMessageTools(),
                canTranslate = mw.translate.canTranslate();

            var $editorColumn = $( '<div>' )
                .addClass( 'seven columns editcolumn' );

            var $messageKeyLabel = $( '<div>' )
                .addClass( 'ten columns messagekey' )
                .text( this.message.title )
                .append(
                    $( '<span>' ).addClass( 'caret' ),
                    $messageTools
                )
                .on( 'click', function ( e ) {
                    $messageTools.toggleClass( 'hide' );
                    e.stopPropagation();
                } );

            var $closeIcon = $( '<span>' )
                .addClass( 'one column close' )
                .attr( 'title', mw.msg( 'tux-editor-close-tooltip' ) )
                .on( 'click', function ( e ) {
                    translateEditor.hide();
                    e.stopPropagation();
                } );

            var $infoToggleIcon = $( '<span>' )
                // Initially the editor column is contracted,
                // so show the expand button first
                .addClass( 'one column editor-info-toggle editor-expand' )
                .attr( 'title', mw.msg( 'tux-editor-expand-tooltip' ) )
                .on( 'click', function ( e ) {
                    translateEditor.infoToggle( $( this ) );
                    e.stopPropagation();
                } );

            var $layoutActions = $( '<div>' )
                .addClass( 'two columns layout-actions' )
                .append( $closeIcon, $infoToggleIcon );

            $editorColumn.append( $( '<div>' )
                .addClass( 'row tux-editor-titletools' )
                .append( $messageKeyLabel, $layoutActions )
            );

            var $messageList = $( '.tux-messagelist' );
            var originalTranslation = this.message.translation;
            var sourceString = this.message.definition;
            // The following classes are used here:
            // * mw-editfont-serif
            // * mw-editfont-sans-serif
            // * mw-editfont-monospace
            var $sourceString = $( '<span>' )
                .addClass( 'twelve columns sourcemessage ' + this.editFontClass )
                .attr( {
                    lang: $messageList.data( 'sourcelangcode' ),
                    dir: $messageList.data( 'sourcelangdir' )
                } )
                .text( sourceString );

            // Adjust the font size for the message string based on the length
            if ( sourceString.length > 100 && sourceString.length < 200 ) {
                $sourceString.addClass( 'long' );
            }

            if ( sourceString.length > 200 ) {
                $sourceString.addClass( 'longer' );
            }

            var $copyOriginalButton = null;
            if ( window.navigator.clipboard ) {
                $copyOriginalButton = $( '<button>' )
                    .addClass( 'tux-editor-copy-original-button' )
                    .prop( 'title', mw.msg( 'tux-editor-copy-original-button-label' ) )
                    .on( 'click', function () {
                        window.navigator.clipboard.writeText( sourceString );
                        var $self = $( this );
                        $self
                            .addClass( 'copied' )
                            .prop( {
                                disabled: true,
                                title: mw.msg( 'tux-editor-copied-original-button-label' )
                            } );
                        setTimeout( function () {
                            $self
                                .prop( {
                                    disabled: false,
                                    title: mw.msg( 'tux-editor-copy-original-button-label' )
                                } )
                                .removeClass( 'copied' );
                        }, 2000 );
                    } );
            }

            $editorColumn.append( $( '<div>' )
                .addClass( 'row tux-editor-sourcemessage-container' )
                .append( $sourceString, $copyOriginalButton )
            );

            var $notices = $( '<div>' )
                .addClass( 'tux-notice hide' );

            var $moreNoticesTab = $( '<div>' )
                .addClass( 'tux-more-notices hide' )
                .on( 'click', function () {
                    var $this = $( this ),
                        $moreNotices = $notices.children(),
                        lastNoticeIndex = $moreNotices.length - 1;

                    // If the notice list is not open, only one notice is shown
                    if ( $this.hasClass( 'open' ) ) {
                        $moreNotices.each( function ( index, element ) {
                            // The first element must always be shown
                            if ( index ) {
                                $( element ).addClass( 'hide' );
                            }
                        } );

                        $this
                            .removeClass( 'open' )
                            .text( mw.msg( 'tux-notices-more', lastNoticeIndex ) );
                    } else {
                        $moreNotices.each( function ( index, element ) {
                            // The first element must always be shown
                            if ( index ) {
                                $( element ).removeClass( 'hide' );
                            }
                        } );

                        $this
                            .addClass( 'open' )
                            .text( mw.msg( 'tux-notices-hide' ) );
                    }

                    translateEditor.toggleMoreButtonClass();
                } );

            var $textarea = this.getTranslationEditor( this.message.targetLanguage );

            // Shortcuts for various insertable things
            $textarea.on( 'keyup keydown', function ( e ) {
                var index, $info, direction;

                if ( e.type === 'keydown' && e.altKey === true ) {
                    // Up and down arrows
                    if ( e.keyCode === 38 || e.keyCode === 40 ) {
                        direction = e.keyCode === 40 ? 1 : -1;
                        $info = translateEditor.$editor.find( '.infocolumn' );
                        $info.scrollTop( $info.scrollTop() + 100 * direction );
                        translateEditor.showShortcuts();
                    }
                }

                // Move zero to last
                index = e.keyCode - 49;
                if ( index === -1 ) {
                    index = 9;
                }

                // 0..9 ~ 48..57
                if (
                    e.type === 'keydown' &&
                    e.altKey === true &&
                    e.ctrlKey === false &&
                    e.shiftKey === false &&
                    index >= 0 && index < 10
                ) {
                    e.preventDefault();
                    e.stopPropagation();
                    translateEditor.$editor.find( '.shortcut-activated:visible' ).eq( index ).trigger( 'click' );
                    // Update numbers and locations after trigger should be completed
                    window.setTimeout( function () {
                        translateEditor.showShortcuts();
                    }, 100 );
                }

                if ( e.which === 18 && e.type === 'keyup' ) {
                    translateEditor.hideShortcuts();
                } else if ( e.which === 18 && e.type === 'keydown' ) {
                    translateEditor.showShortcuts();
                }
            } );

            $textarea.on( 'input', function () {
                var $pasteSourceButton = translateEditor.$editor.find( '.tux-editor-paste-original-button' ),
                    original = translateEditor.message.translation || '',
                    current = $textarea.val() || '';

                if ( original !== '' ) {
                    $discardChangesButton.removeClass( 'hide' );
                }

                /* Avoid Unsaved marking when translated message is not changed in content.
                 * - translateEditor.dirty: internal book keeping
                 * - mw.translate.dirty: "you have unchanged edits" notice
                 */
                if ( original === current ) {
                    translateEditor.markUnunsaved();
                } else {
                    translateEditor.dirty = true;
                    mw.translate.dirty = true;
                }

                translateEditor.makeSaveButtonJustSave( $saveButton );

                // When there is content in the editor enable the button.
                // But do not enable when some saving is not finished yet.
                var enabled = current.trim() && !translateEditor.saving;
                $saveButton.prop( 'disabled', !enabled );
                $pasteSourceButton.toggleClass( 'hide', enabled );

                translateEditor.resizeInsertables( $textarea );

                translateEditor.delayValidation( function () {
                    translateEditor.validateTranslation();
                }, 1000 );
            } );

            var $noticesBlock = $( '<div>' )
                .addClass( 'tux-notices-block' )
                .append( $moreNoticesTab, $notices );

            var $editAreaBlock = $( '<div>' )
                .addClass( 'row tux-editor-editarea-block' )
                .append( $( '<div>' )
                    .addClass( 'editarea twelve columns' )
                    .append( $noticesBlock, $textarea )
                );

            $editorColumn.append( $editAreaBlock );

            var $editingButtonBlock, $editSummaryBlock, $requestRight, $skipButton;
            if ( canTranslate ) {
                var $pasteOriginalButton = $( '<button>' )
                    .addClass( 'tux-editor-paste-original-button' )
                    .text( mw.msg( 'tux-editor-paste-original-button-label' ) )
                    .on( 'click', function () {
                        $textarea
                            .trigger( 'focus' )
                            .val( sourceString )
                            .trigger( 'input' );

                        $pasteOriginalButton.addClass( 'hide' );
                    } );

                var $editSummary = $( '<input>' )
                    .addClass( 'tux-input-editsummary' )
                    .attr( {
                        maxlength: 255,
                        disabled: true,
                        placeholder: mw.msg( 'tux-editor-editsummary-placeholder' )
                    } )
                    .val( '' );

                // Enable edit summary if there was a change to translation area
                // or disable if there is no text in translation area
                $textarea.on( 'input', function () {
                    if ( $editSummary.prop( 'disabled' ) ) {
                        $editSummary.prop( 'disabled', false );
                    }
                    if ( $textarea.val().trim() === '' ) {
                        $editSummary.prop( 'disabled', true );
                    }
                } ).on( 'keydown', function ( e ) {
                    if ( !e.ctrlKey || e.keyCode !== 13 ) {
                        return;
                    }

                    if ( !$saveButton.is( ':disabled' ) ) {
                        $saveButton.trigger( 'click' );
                        return;
                    }
                    $skipButton.trigger( 'click' );
                } );

                // Make the Ctrl+Enter shortcut work in the edit summary field
                $editSummary.on( 'keydown', function ( e ) {
                    if ( !e.ctrlKey || e.keyCode !== 13 ) {
                        return;
                    }

                    $saveButton.trigger( 'click' );
                } );

                if ( originalTranslation !== null ) {
                    $discardChangesButton = $( '<button>' )
                        .addClass( 'tux-editor-discard-changes-button hide' ) // Initially hidden
                        .text( mw.msg( 'tux-editor-discard-changes-button-label' ) )
                        .on( 'click', function () {
                            // Restore the translation
                            $textarea
                                .trigger( 'focus' )
                                .val( originalTranslation );

                            // and go back to hiding.
                            $discardChangesButton.addClass( 'hide' );

                            // There's nothing new to save...
                            $editSummary.val( '' ).prop( 'disabled', true );
                            $saveButton.prop( 'disabled', true );
                            // ...unless there is other action
                            translateEditor.makeSaveButtonContextSensitive( $saveButton );

                            translateEditor.markUnunsaved();
                            translateEditor.resizeInsertables( $textarea );
                        } );
                }

                if ( this.message.translation ) {
                    $pasteOriginalButton.addClass( 'hide' );
                }

                $editingButtonBlock = $( '<div>' )
                    .addClass( 'twelve columns tux-editor-insert-buttons' )
                    .append(
                        $pasteOriginalButton,
                        $discardChangesButton
                    );

                $editSummaryBlock = $( '<div>' )
                    .addClass( 'row tux-editor-editsummary-block' )
                    .append(
                        $( '<div>' )
                            .addClass( 'twelve columns' )
                            .append( $editSummary )
                    );

                $requestRight = $( [] );

                $saveButton = $( '<button>' )
                    .prop( 'disabled', true )
                    .addClass( 'tux-editor-save-button mw-ui-button mw-ui-progressive' )
                    .text( mw.msg( 'tux-editor-save-button-label' ) )
                    .on( 'click', function ( e ) {
                        translateEditor.save();
                        e.stopPropagation();
                    } );

                this.makeSaveButtonContextSensitive( $saveButton, this.$messageItem );
            } else {
                $editingButtonBlock = $( [] );

                $editSummaryBlock = $( [] );

                $requestRight = $( '<span>' )
                    .addClass( 'tux-editor-request-right' )
                    .text( mw.msg( 'translate-edit-nopermission' ) );
                // Make sure wgTranslatePermissionUrl setting is not 'false'
                if ( mw.config.get( 'wgTranslatePermissionUrl' ) !== false ) {
                    $requestRight
                        .append( $( '<a>' )
                            .text( mw.msg( 'translate-edit-askpermission' ) )
                            .addClass( 'tux-editor-ask-permission' )
                            .attr( {
                                href: mw.util.getUrl(
                                    mw.config.get( 'wgTranslateUseSandbox' ) ?
                                        'Special:TranslationStash' :
                                        mw.config.get( 'wgTranslatePermissionUrl' )
                                )
                            } )
                        );
                }
                // Disable the text area if user has no translation rights.
                // Use readonly to allow copy-pasting (except for placeholders)
                $textarea.prop( 'readonly', true );

                $saveButton = $( [] );
            }

            $skipButton = $( '<button>' )
                .addClass( 'tux-editor-skip-button mw-ui-button mw-ui-quiet' )
                .text( mw.msg( 'tux-editor-skip-button-label' ) )
                .on( 'click', function ( e ) {
                    translateEditor.skip();
                    translateEditor.next();

                    if ( translateEditor.options.onSkip ) {
                        translateEditor.options.onSkip.call( translateEditor );
                    }

                    e.stopPropagation();
                } );

            // This appears instead of "Skip" on the last message on the page
            var $cancelButton = $( '<button>' )
                .addClass( 'tux-editor-cancel-button mw-ui-button mw-ui-quiet' )
                .text( mw.msg( 'tux-editor-cancel-button-label' ) )
                .on( 'click', function ( e ) {
                    translateEditor.skip();
                    translateEditor.hide();

                    e.stopPropagation();
                } );

            var $controlButtonBlock = $( '<div>' )
                .addClass( 'twelve columns tux-editor-control-buttons' )
                .append( $requestRight, $saveButton, $skipButton, $cancelButton );

            $editorColumn.append(
                $( '<div>' )
                    .addClass( 'row tux-editor-actions-block' )
                    .append( $editingButtonBlock ),
                $editSummaryBlock,
                $( '<div>' )
                    .addClass( 'row tux-editor-actions-block' )
                    .append( $controlButtonBlock )
            );

            if ( canTranslate ) {
                var prefix = $.fn.updateTooltipAccessKeys.getAccessKeyPrefix();
                $editorColumn.append( $( '<div>' )
                    .addClass( 'row shortcutinfo' )
                    .text( mw.msg(
                        'tux-editor-shortcut-info',
                        'CTRL-ENTER',
                        ( prefix + 'd' ).toUpperCase(),
                        'ALT',
                        ( prefix + 'b' ).toUpperCase()
                    ) )
                );
            }

            return $editorColumn;
        },

        /**
         * Modifies the save button to provide suitable default action for *unchanged*
         * message. It will revert back to normal save button if the text is changed.
         *
         * @private
         * @param {jQuery} $button The save button.
         */
        makeSaveButtonContextSensitive: function ( $button ) {
            var self = this;

            if ( this.message.properties.status === 'fuzzy' ) {
                $button.prop( 'disabled', false )
                    .text( mw.msg( 'tux-editor-confirm-button-label' ) )
                    .off( 'click' )
                    .on( 'click', function ( e ) {
                        self.save();
                        e.stopPropagation();
                    } );
            } else if ( this.message.proofreadable ) {
                $button.prop( 'disabled', false )
                    .text( mw.msg( 'tux-editor-proofread-button-label' ) )
                    .off( 'click' )
                    .on( 'click', function ( e ) {
                        $button.prop( 'disabled', true );
                        self.message.proofreadAction();
                        self.next();
                        e.stopPropagation();
                    } );
            }
        },

        /**
         * Modifies the save button to just save the translation as usual. Whether the
         * button is enabled or not is controlled elsewhere.
         *
         * @private
         * @param {jQuery} $button The save button.
         */
        makeSaveButtonJustSave: function ( $button ) {
            var self = this;

            $button.text( mw.msg( 'tux-editor-save-button-label' ) )
                .off( 'click' )
                .on( 'click', function ( e ) {
                    self.save();
                    e.stopPropagation();
                } );
        },

        /**
         * Validate the current translation using the API
         * and show the notices.
         *
         * @internal
         */
        validateTranslation: function () {
            var translateEditor = this,
                $textarea = translateEditor.$editor.find( '.tux-textarea-translation' );

            var api = new mw.Api();

            this.validating = api.post( {
                action: 'translationcheck',
                title: this.message.title,
                translation: $textarea.val(),
                uselang: mw.config.get( 'wgUserLanguage' )
            } ).done( function ( data ) {
                var warnings = data.validation.warnings,
                    errors = data.validation.errors;

                translateEditor.removeNotices( [ noticeTypes.error, noticeTypes.warning ] );

                if ( ( !warnings || !warnings.length ) &&
                    ( !errors || !errors.length ) ) {
                    return;
                }

                // Remove useless fuzzy notice if we have more details
                translateEditor.removeNotices( noticeTypes.fuzzy );

                // Disable confirm translation button, since fuzzy translations
                // cannot be confirmed. The check for dirty state can be removed
                // to prevent translations with notices.
                if ( !translateEditor.dirty ) {
                    translateEditor.$editor.find( '.tux-editor-save-button' )
                        .prop( 'disabled', true );
                }

                // Don't allow users to save if there are errors but allow admins to save
                // even if there are errors.
                if ( !mw.translate.canManage() ) {
                    if ( errors && errors.length > 0 ) {
                        translateEditor.$editor.find( '.tux-editor-save-button' )
                            .prop( 'disabled', true );
                    }
                }

                translateEditor.displayNotices( warnings, noticeTypes.warning );
                translateEditor.displayNotices( errors, noticeTypes.error );

            } ).always( function () {
                translateEditor.validating = null;
            } );
        },

        /**
         * Remove all notices of given types
         *
         * @internal
         * @param {(string|string[])} types
         */
        removeNotices: function ( types ) {
            var $tuxNotice = this.$editor.find( '.tux-notice' ),
                stringTypes = [],
                allNoticeTypes = noticeTypes.getAllClasses();

            if ( typeof types === 'string' ) {
                stringTypes.push( types );
            } else {
                stringTypes = types;
            }

            for ( var index = 0; index < stringTypes.length; index++ ) {
                if ( allNoticeTypes.indexOf( stringTypes[ index ] ) === -1 ) {
                    var errMsg = 'tux: Invalid notice type removeNotice - ' + stringTypes[ index ];
                    mw.log.error( errMsg );
                    throw new Error( errMsg );
                }
                $tuxNotice.find( '.' + stringTypes[ index ] ).remove();
            }

            var $currentNotices = $tuxNotice.children();
            // If a single notice is shown, we can hide the more notice button,
            // and display the hidden notice.
            if ( $currentNotices.length <= 1 ) {
                this.$editor.find( '.tux-more-notices' ).addClass( 'hide' );
                $currentNotices.removeClass( 'hide' );
            }
            this.toggleMoreButtonClass();
        },

        /**
         * Displays the supplied notice above the translation edit area.
         * Newer notices are added to the top while older notices are
         * added to the bottom. This also means that older notices will
         * not be shown by default unless the user clicks "more notices" tab.
         *
         * @private
         * @param {string} notice used as html for the notices display
         * @param {string} type used to group the notices.eg: warning, diff, error
         * @return {jQuery} the new notice element
         */
        addNotice: function ( notice, type ) {
            var $notices = this.$editor.find( '.tux-notice' ),
                $moreNoticesTab = this.$editor.find( '.tux-more-notices' ),
                // `noticeTypes` documented above
                // eslint-disable-next-line mediawiki/class-doc
                $newNotice = $( '<div>' )
                    .addClass( 'tux-notice-message ' + type )
                    .html( notice );

            this.$editor.find( '.tux-notice-message' ).addClass( 'hide' );

            $notices
                .removeClass( 'hide' )
                .prepend( $newNotice );

            var noticeCount = $notices.find( '.tux-notice-message' ).length;

            if ( noticeCount > 1 ) {
                $moreNoticesTab
                    .text( mw.msg( 'tux-notices-more', noticeCount - 1 ) )
                    .removeClass( 'hide open' );
            } else {
                $moreNoticesTab.addClass( 'hide' );
            }
            this.toggleMoreButtonClass();

            return $newNotice;
        },

        /**
         * Toggles the class on the more button based on the types of notice displayed, and whether
         * the more section is expanded. This is done in order to change the background color of the
         * button.
         *
         * @private
         */
        toggleMoreButtonClass: function () {
            var $allNotices = this.$editor.find( '.tux-notice-message' ),
                errorCount = $allNotices.filter( '.tux-notice-message.' + noticeTypes.error ).length +
                    $allNotices.filter( '.tux-notice-message.' + noticeTypes.translateFail ).length,
                otherErrorsCount = $allNotices.length - errorCount,
                $moreButton = this.$editor.find( '.tux-more-notices' );

            // there are other notices, and more section is expanded.
            var expanded = otherErrorsCount > 0 && $moreButton.hasClass( 'open' );
            $moreButton.toggleClass( 'tux-has-errors', errorCount > 0 && !expanded );
        },

        /**
         * @private
         * @return {jQuery}
         */
        prepareInfoColumn: function () {
            var $infoColumn = $( '<div>' ).addClass( 'infocolumn' ),
                translateEditor = this;

            $infoColumn.append( $( '<div>' )
                .addClass( 'row loading' )
                .text( mw.msg( 'tux-editor-loading' ) )
            );

            if ( mw.config.get( 'wgTranslateDocumentationLanguageCode' ) ) {
                var $messageDescSaveButton = $( '<button>' )
                    .addClass( 'tux-editor-savedoc-button mw-ui-button mw-ui-progressive' )
                    .prop( 'disabled', true )
                    .text( mw.msg( 'tux-editor-doc-editor-save' ) )
                    .on( 'click', function () {
                        translateEditor.saveDocumentation()
                            .done( function () {
                                // eslint-disable-next-line no-use-before-define
                                var $descEditLink = $messageDescViewer.find( '.message-desc-edit' );
                                $descEditLink.text( mw.msg( 'tux-editor-edit-desc' ) );
                            } );
                    } );

                var $messageDescCancelButton = $( '<button>' )
                    .addClass( 'tux-editor-skipdoc-button mw-ui-button mw-ui-quiet' )
                    .text( mw.msg( 'tux-editor-doc-editor-cancel' ) )
                    .on( 'click', function () {
                        translateEditor.hideDocumentationEditor();
                    } );

                var $messageDescTextarea = $( '<textarea>' )
                    .addClass( 'tux-textarea-documentation' )
                    .on( 'input', function () {
                        $messageDescSaveButton.prop( 'disabled', false );
                    } )
                    .prop( 'placeholder', mw.msg( 'tux-editor-doc-editor-placeholder' ) );

                var $messageDescEditor = $( '<div>' )
                    .addClass( 'row message-desc-editor hide' )
                    .append(
                        $messageDescTextarea,
                        $( '<div>' )
                            .addClass( 'row' )
                            .append(
                                $messageDescSaveButton,
                                $messageDescCancelButton
                            )
                    );

                var $messageDescViewer = $( '<div>' )
                    .addClass( 'message-desc-viewer hide' )
                    .append(
                        $( '<div>' )
                            .addClass( 'row message-desc mw-parser-output' ),
                        $( '<div>' )
                            .addClass( 'row message-desc-control' )
                            .append( $( '<a>' )
                                .attr( {
                                    href: mw.translate.getDocumentationEditURL(
                                        this.message.title.replace( /\/[a-z-]+$/, '' )
                                    ),
                                    target: '_blank'
                                } )
                                .addClass( 'message-desc-edit' )
                                .on( 'click', this.showDocumentationEditor.bind( this ) )
                            )
                    );

                if ( !mw.translate.canTranslate() ) {
                    $messageDescViewer.find( '.message-desc-control' ).addClass( 'hide' );
                }

                $infoColumn.append(
                    $messageDescEditor,
                    $messageDescViewer
                );
            }

            $infoColumn.append( $( '<div>' )
                .addClass( 'row uneditable-documentation hide mw-parser-output' )
            );

            $infoColumn.append( $( '<div>' )
                .addClass( 'row edit-summaries-title hide' )
                .append(
                    $( '<span>' ).text( mw.msg( 'tux-editor-latest-updates-title' ) )
                )
                .append( $( '<a>' )
                    .attr(
                        {
                            href: mw.util.getUrl( this.message.title, { action: 'history' } ),
                            target: '_blank'
                        }
                    )
                    .text( mw.msg( 'tux-editor-all-changes' ) )
                    .addClass( 'edit-summaries-all-changes' ) ) );

            $infoColumn.append( $( '<div>' )
                .addClass( 'row tm-suggestions-title hide' )
                .text( mw.msg( 'tux-editor-suggestions-title' ) )
            );

            $infoColumn.append( $( '<div>' )
                .addClass( 'row in-other-languages-title hide' )
                .text( mw.msg( 'tux-editor-in-other-languages' ) )
            );

            // The actual href is set when translationhelpers are loaded
            $infoColumn.append( $( '<div>' )
                .addClass( 'row help hide' )
                .append(
                    $( '<span>' )
                        .text( mw.msg( 'tux-editor-need-more-help' ) ),
                    $( '<a>' )
                        .attr( {
                            href: '#',
                            target: '_blank'
                        } )
                        .text( mw.msg( 'tux-editor-ask-help' ) )
                )
            );

            return $( '<div>' )
                .addClass( 'five columns infocolumn-block' )
                .append(
                    $( '<span>' ).addClass( 'tux-message-editor__caret' ),
                    $infoColumn
                );
        },

        /**
         * @internal
         * @return {boolean}
         */
        show: function () {
            if ( !this.$editor ) {
                this.init();
            }

            var $textarea = this.$editor.find( '.editcolumn textarea' );
            // Hide all other open editors in the page
            $( '.tux-message.open' ).each( function () {
                $( this ).data( 'translateeditor' ).hide();
            } );

            this.$editor.find( '.tux-editor-save-button' ).attr( 'accesskey', 's' );
            this.$editor.find( '.tux-editor-skip-button' ).attr( 'accesskey', 'd' );
            this.$editor.find( '.tux-input-editsummary' ).attr( 'accesskey', 'b' );
            // @todo access key for the cancel button

            this.$messageItem.addClass( 'hide' );
            this.$editor.removeClass( 'hide' );
            $textarea.trigger( 'focus' );

            autosize( $textarea );
            this.resizeInsertables( $textarea );

            this.shown = true;
            this.$editTrigger.addClass( 'open' );

            // don't waste time, get ready with next message
            var $next = this.$editTrigger.next( '.tux-message' );

            if ( $next.length ) {
                $next.data( 'translateeditor' ).init();
            }

            mw.hook( 'mw.translate.editor.afterEditorShown' ).fire( this.$editor );

            return false;
        },

        /**
         * @private
         * @return {boolean}
         */
        hide: function () {
            // If the user has made changes, make sure they are either
            // in process of being saved or highlighted as unsaved.
            if ( this.dirty ) {
                if ( this.saving ) {
                    this.markSaving();
                } else {
                    this.markUnsaved();
                }
            }

            if ( this.$editor ) {
                this.$editor.addClass( 'hide' );

                // Remove access keys to avoid duplicates in DOM (T306141)
                this.$editor.find( '.tux-editor-save-button' ).removeAttr( 'accesskey' );
                this.$editor.find( '.tux-editor-skip-button' ).removeAttr( 'accesskey' );
                this.$editor.find( '.tux-input-editsummary' ).removeAttr( 'accesskey' );
            }

            this.hideShortcuts();
            this.$editTrigger.removeClass( 'open' );
            this.$messageItem.removeClass( 'hide' );
            this.shown = false;

            return false;
        },

        /**
         * @private
         * @param {jQuery} toggleIcon
         */
        infoToggle: function ( toggleIcon ) {
            this.expanded = !this.expanded;

            // Change the icon image
            toggleIcon
                .toggleClass( 'editor-expand', !this.expanded )
                .toggleClass( 'editor-contract', this.expanded )
                .attr( 'title', mw.msg( this.expanded ? 'tux-editor-collapse-tooltip' : 'tux-editor-expand-tooltip' ) );

            this.$editor.toggleClass( 'tux-message-editor--expanded', this.expanded );
        },

        /**
         * Adds the diff between old and current definitions to the view.
         *
         * @internal
         * @param {Object} definitiondiff A definitiondiff object as returned by API.
         */
        addDefinitionDiff: function ( definitiondiff ) {
            if ( !definitiondiff || definitiondiff.error ) {
                mw.log( 'Error loading translation diff ' + definitiondiff && definitiondiff.error );
                return;
            }

            // Load the diff styles
            mw.loader.load( 'mediawiki.diff.styles' );

            var $trigger = $( '<span>' )
                .addClass( 'show-diff-link' )
                .text( mw.msg( 'tux-editor-outdated-notice-diff-link' ) )
                .on( 'click', function () {
                    $( this ).parent().html( definitiondiff.html );
                } );

            this.removeNotices( noticeTypes.fuzzy );
            this.addNotice(
                mw.message( 'tux-editor-outdated-notice' ).escaped(),
                noticeTypes.diff
            ).append( $trigger );
        },

        /**
         * Attach event listeners
         *
         * @internal
         */
        listen: function () {
            var translateEditor = this;

            this.$editTrigger.find( '.tux-message-item' ).on( 'click', function () {
                translateEditor.show();

                return false;
            } );
        },

        /**
         * Makes the textarea large enough for insertables and positions the insertables.
         *
         * @internal
         * @param {jQuery} $textarea Text area.
         */
        resizeInsertables: function ( $textarea ) {
            var $buttonArea = this.$editor.find( '.tux-editor-insert-buttons' );
            var buttonAreaHeight = $buttonArea.height();
            $textarea.css( 'padding-bottom', buttonAreaHeight + 5 );
            $buttonArea.css( 'top', -buttonAreaHeight );
            autosize.update( $textarea );
        },

        /**
         * Utility method to display a list of notices on the UI
         *
         * @private
         * @param {string[]} notices
         * @param {string} noticeType
         */
        displayNotices: function ( notices, noticeType ) {
            for ( var index = 0; index < notices.length; ++index ) {
                this.addNotice( notices[ index ], noticeType );
            }
        },

        /**
         * Ensures that all the notices are displayed
         *
         * @private
         */
        showMoreNotices: function () {
            var $moreNoticesTab = this.$editor.find( '.tux-more-notices' );
            if ( $moreNoticesTab.hasClass( 'open' ) ) {
                return;
            }

            $moreNoticesTab.trigger( 'click' );
        },

        /**
         * Generates the translation editor element based on target language
         *
         * @private
         * @param {string} targetLangCode
         * @return {jQuery} Returns translation editor element
         */
        getTranslationEditor: function ( targetLangCode ) {
            var targetLangAttrib, placeholder;
            if ( targetLangCode === mw.config.get( 'wgTranslateDocumentationLanguageCode' ) ) {
                targetLangAttrib = mw.config.get( 'wgContentLanguage' );
                placeholder = mw.msg( 'tux-editor-placeholder-documentation' );
            } else {
                var userLangCode = mw.config.get( 'wgUserLanguage' );
                var targetLangName = mw.language.getData( userLangCode, 'languageNames' )[ targetLangCode ] || $.uls.data.getAutonym( targetLangCode );
                targetLangAttrib = targetLangCode;
                placeholder = mw.msg( 'tux-editor-placeholder-language', targetLangName );
            }

            var targetLangDir = $.uls.data.getDir( targetLangAttrib );

            // The following classes are used here:
            // * mw-editfont-serif
            // * mw-editfont-sans-serif
            // * mw-editfont-monospace
            return $( '<textarea>' )
                .addClass( 'tux-textarea-translation ' + this.editFontClass )
                .attr( {
                    lang: targetLangAttrib,
                    dir: targetLangDir
                } )
                .val( this.message.translation || '' )
                .prop( 'placeholder', placeholder );
        }
    };

    /**
     * translateeditor PLUGIN DEFINITION
     *
     * @internal
     * @param {Object} options
     * @returns {jQuery}
     */
    $.fn.translateeditor = function ( options ) {
        return this.each( function () {
            var $this = $( this ),
                data = $this.data( 'translateeditor' );

            if ( !data ) {
                $this.data( 'translateeditor',
                    ( data = new TranslateEditor( this, options ) )
                );
            }

            if ( typeof options === 'string' ) {
                data[ options ].call( $this );
            }
        } );
    };

    mw.translate.editor = mw.translate.editor || {};
    mw.translate.editor = $.extend( TranslateEditor.prototype, mw.translate.editor );

    function delayer() {
        return ( function () {
            var timer = 0;

            return function ( callback, milliseconds ) {
                clearTimeout( timer );

                if ( callback === false ) {
                    // sometimes we need to just cancel the timer without
                    // setting up another one
                    return;
                }

                timer = setTimeout( callback, milliseconds );
            };
        }() );
    }
}() );