WordPress/WordPress

View on GitHub
wp-admin/js/widgets/custom-html-widgets.js

Summary

Maintainability
F
5 days
Test Coverage
/**
 * @output wp-admin/js/widgets/custom-html-widgets.js
 */

/* global wp */
/* eslint consistent-this: [ "error", "control" ] */
/* eslint no-magic-numbers: ["error", { "ignore": [0,1,-1] }] */

/**
 * @namespace wp.customHtmlWidget
 * @memberOf wp
 */
wp.customHtmlWidgets = ( function( $ ) {
    'use strict';

    var component = {
        idBases: [ 'custom_html' ],
        codeEditorSettings: {},
        l10n: {
            errorNotice: {
                singular: '',
                plural: ''
            }
        }
    };

    component.CustomHtmlWidgetControl = Backbone.View.extend(/** @lends wp.customHtmlWidgets.CustomHtmlWidgetControl.prototype */{

        /**
         * View events.
         *
         * @type {Object}
         */
        events: {},

        /**
         * Text widget control.
         *
         * @constructs wp.customHtmlWidgets.CustomHtmlWidgetControl
         * @augments Backbone.View
         * @abstract
         *
         * @param {Object} options - Options.
         * @param {jQuery} options.el - Control field container element.
         * @param {jQuery} options.syncContainer - Container element where fields are synced for the server.
         *
         * @return {void}
         */
        initialize: function initialize( options ) {
            var control = this;

            if ( ! options.el ) {
                throw new Error( 'Missing options.el' );
            }
            if ( ! options.syncContainer ) {
                throw new Error( 'Missing options.syncContainer' );
            }

            Backbone.View.prototype.initialize.call( control, options );
            control.syncContainer = options.syncContainer;
            control.widgetIdBase = control.syncContainer.parent().find( '.id_base' ).val();
            control.widgetNumber = control.syncContainer.parent().find( '.widget_number' ).val();
            control.customizeSettingId = 'widget_' + control.widgetIdBase + '[' + String( control.widgetNumber ) + ']';

            control.$el.addClass( 'custom-html-widget-fields' );
            control.$el.html( wp.template( 'widget-custom-html-control-fields' )( { codeEditorDisabled: component.codeEditorSettings.disabled } ) );

            control.errorNoticeContainer = control.$el.find( '.code-editor-error-container' );
            control.currentErrorAnnotations = [];
            control.saveButton = control.syncContainer.add( control.syncContainer.parent().find( '.widget-control-actions' ) ).find( '.widget-control-save, #savewidget' );
            control.saveButton.addClass( 'custom-html-widget-save-button' ); // To facilitate style targeting.

            control.fields = {
                title: control.$el.find( '.title' ),
                content: control.$el.find( '.content' )
            };

            // Sync input fields to hidden sync fields which actually get sent to the server.
            _.each( control.fields, function( fieldInput, fieldName ) {
                fieldInput.on( 'input change', function updateSyncField() {
                    var syncInput = control.syncContainer.find( '.sync-input.' + fieldName );
                    if ( syncInput.val() !== fieldInput.val() ) {
                        syncInput.val( fieldInput.val() );
                        syncInput.trigger( 'change' );
                    }
                });

                // Note that syncInput cannot be re-used because it will be destroyed with each widget-updated event.
                fieldInput.val( control.syncContainer.find( '.sync-input.' + fieldName ).val() );
            });
        },

        /**
         * Update input fields from the sync fields.
         *
         * This function is called at the widget-updated and widget-synced events.
         * A field will only be updated if it is not currently focused, to avoid
         * overwriting content that the user is entering.
         *
         * @return {void}
         */
        updateFields: function updateFields() {
            var control = this, syncInput;

            if ( ! control.fields.title.is( document.activeElement ) ) {
                syncInput = control.syncContainer.find( '.sync-input.title' );
                control.fields.title.val( syncInput.val() );
            }

            /*
             * Prevent updating content when the editor is focused or if there are current error annotations,
             * to prevent the editor's contents from getting sanitized as soon as a user removes focus from
             * the editor. This is particularly important for users who cannot unfiltered_html.
             */
            control.contentUpdateBypassed = control.fields.content.is( document.activeElement ) || control.editor && control.editor.codemirror.state.focused || 0 !== control.currentErrorAnnotations.length;
            if ( ! control.contentUpdateBypassed ) {
                syncInput = control.syncContainer.find( '.sync-input.content' );
                control.fields.content.val( syncInput.val() );
            }
        },

        /**
         * Show linting error notice.
         *
         * @param {Array} errorAnnotations - Error annotations.
         * @return {void}
         */
        updateErrorNotice: function( errorAnnotations ) {
            var control = this, errorNotice, message = '', customizeSetting;

            if ( 1 === errorAnnotations.length ) {
                message = component.l10n.errorNotice.singular.replace( '%d', '1' );
            } else if ( errorAnnotations.length > 1 ) {
                message = component.l10n.errorNotice.plural.replace( '%d', String( errorAnnotations.length ) );
            }

            if ( control.fields.content[0].setCustomValidity ) {
                control.fields.content[0].setCustomValidity( message );
            }

            if ( wp.customize && wp.customize.has( control.customizeSettingId ) ) {
                customizeSetting = wp.customize( control.customizeSettingId );
                customizeSetting.notifications.remove( 'htmlhint_error' );
                if ( 0 !== errorAnnotations.length ) {
                    customizeSetting.notifications.add( 'htmlhint_error', new wp.customize.Notification( 'htmlhint_error', {
                        message: message,
                        type: 'error'
                    } ) );
                }
            } else if ( 0 !== errorAnnotations.length ) {
                errorNotice = $( '<div class="inline notice notice-error notice-alt"></div>' );
                errorNotice.append( $( '<p></p>', {
                    text: message
                } ) );
                control.errorNoticeContainer.empty();
                control.errorNoticeContainer.append( errorNotice );
                control.errorNoticeContainer.slideDown( 'fast' );
                wp.a11y.speak( message );
            } else {
                control.errorNoticeContainer.slideUp( 'fast' );
            }
        },

        /**
         * Initialize editor.
         *
         * @return {void}
         */
        initializeEditor: function initializeEditor() {
            var control = this, settings;

            if ( component.codeEditorSettings.disabled ) {
                return;
            }

            settings = _.extend( {}, component.codeEditorSettings, {

                /**
                 * Handle tabbing to the field before the editor.
                 *
                 * @ignore
                 *
                 * @return {void}
                 */
                onTabPrevious: function onTabPrevious() {
                    control.fields.title.focus();
                },

                /**
                 * Handle tabbing to the field after the editor.
                 *
                 * @ignore
                 *
                 * @return {void}
                 */
                onTabNext: function onTabNext() {
                    var tabbables = control.syncContainer.add( control.syncContainer.parent().find( '.widget-position, .widget-control-actions' ) ).find( ':tabbable' );
                    tabbables.first().focus();
                },

                /**
                 * Disable save button and store linting errors for use in updateFields.
                 *
                 * @ignore
                 *
                 * @param {Array} errorAnnotations - Error notifications.
                 * @return {void}
                 */
                onChangeLintingErrors: function onChangeLintingErrors( errorAnnotations ) {
                    control.currentErrorAnnotations = errorAnnotations;
                },

                /**
                 * Update error notice.
                 *
                 * @ignore
                 *
                 * @param {Array} errorAnnotations - Error annotations.
                 * @return {void}
                 */
                onUpdateErrorNotice: function onUpdateErrorNotice( errorAnnotations ) {
                    control.saveButton.toggleClass( 'validation-blocked disabled', errorAnnotations.length > 0 );
                    control.updateErrorNotice( errorAnnotations );
                }
            });

            control.editor = wp.codeEditor.initialize( control.fields.content, settings );

            // Improve the editor accessibility.
            $( control.editor.codemirror.display.lineDiv )
                .attr({
                    role: 'textbox',
                    'aria-multiline': 'true',
                    'aria-labelledby': control.fields.content[0].id + '-label',
                    'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4'
                });

            // Focus the editor when clicking on its label.
            $( '#' + control.fields.content[0].id + '-label' ).on( 'click', function() {
                control.editor.codemirror.focus();
            });

            control.fields.content.on( 'change', function() {
                if ( this.value !== control.editor.codemirror.getValue() ) {
                    control.editor.codemirror.setValue( this.value );
                }
            });
            control.editor.codemirror.on( 'change', function() {
                var value = control.editor.codemirror.getValue();
                if ( value !== control.fields.content.val() ) {
                    control.fields.content.val( value ).trigger( 'change' );
                }
            });

            // Make sure the editor gets updated if the content was updated on the server (sanitization) but not updated in the editor since it was focused.
            control.editor.codemirror.on( 'blur', function() {
                if ( control.contentUpdateBypassed ) {
                    control.syncContainer.find( '.sync-input.content' ).trigger( 'change' );
                }
            });

            // Prevent hitting Esc from collapsing the widget control.
            if ( wp.customize ) {
                control.editor.codemirror.on( 'keydown', function onKeydown( codemirror, event ) {
                    var escKeyCode = 27;
                    if ( escKeyCode === event.keyCode ) {
                        event.stopPropagation();
                    }
                });
            }
        }
    });

    /**
     * Mapping of widget ID to instances of CustomHtmlWidgetControl subclasses.
     *
     * @alias wp.customHtmlWidgets.widgetControls
     *
     * @type {Object.<string, wp.textWidgets.CustomHtmlWidgetControl>}
     */
    component.widgetControls = {};

    /**
     * Handle widget being added or initialized for the first time at the widget-added event.
     *
     * @alias wp.customHtmlWidgets.handleWidgetAdded
     *
     * @param {jQuery.Event} event - Event.
     * @param {jQuery}       widgetContainer - Widget container element.
     *
     * @return {void}
     */
    component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
        var widgetForm, idBase, widgetControl, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone, fieldContainer, syncContainer;
        widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.

        idBase = widgetForm.find( '> .id_base' ).val();
        if ( -1 === component.idBases.indexOf( idBase ) ) {
            return;
        }

        // Prevent initializing already-added widgets.
        widgetId = widgetForm.find( '.widget-id' ).val();
        if ( component.widgetControls[ widgetId ] ) {
            return;
        }

        /*
         * Create a container element for the widget control fields.
         * This is inserted into the DOM immediately before the the .widget-content
         * element because the contents of this element are essentially "managed"
         * by PHP, where each widget update cause the entire element to be emptied
         * and replaced with the rendered output of WP_Widget::form() which is
         * sent back in Ajax request made to save/update the widget instance.
         * To prevent a "flash of replaced DOM elements and re-initialized JS
         * components", the JS template is rendered outside of the normal form
         * container.
         */
        fieldContainer = $( '<div></div>' );
        syncContainer = widgetContainer.find( '.widget-content:first' );
        syncContainer.before( fieldContainer );

        widgetControl = new component.CustomHtmlWidgetControl({
            el: fieldContainer,
            syncContainer: syncContainer
        });

        component.widgetControls[ widgetId ] = widgetControl;

        /*
         * Render the widget once the widget parent's container finishes animating,
         * as the widget-added event fires with a slideDown of the container.
         * This ensures that the textarea is visible and the editor can be initialized.
         */
        renderWhenAnimationDone = function() {
            if ( ! ( wp.customize ? widgetContainer.parent().hasClass( 'expanded' ) : widgetContainer.hasClass( 'open' ) ) ) { // Core merge: The wp.customize condition can be eliminated with this change being in core: https://github.com/xwp/wordpress-develop/pull/247/commits/5322387d
                setTimeout( renderWhenAnimationDone, animatedCheckDelay );
            } else {
                widgetControl.initializeEditor();
            }
        };
        renderWhenAnimationDone();
    };

    /**
     * Setup widget in accessibility mode.
     *
     * @alias wp.customHtmlWidgets.setupAccessibleMode
     *
     * @return {void}
     */
    component.setupAccessibleMode = function setupAccessibleMode() {
        var widgetForm, idBase, widgetControl, fieldContainer, syncContainer;
        widgetForm = $( '.editwidget > form' );
        if ( 0 === widgetForm.length ) {
            return;
        }

        idBase = widgetForm.find( '.id_base' ).val();
        if ( -1 === component.idBases.indexOf( idBase ) ) {
            return;
        }

        fieldContainer = $( '<div></div>' );
        syncContainer = widgetForm.find( '> .widget-inside' );
        syncContainer.before( fieldContainer );

        widgetControl = new component.CustomHtmlWidgetControl({
            el: fieldContainer,
            syncContainer: syncContainer
        });

        widgetControl.initializeEditor();
    };

    /**
     * Sync widget instance data sanitized from server back onto widget model.
     *
     * This gets called via the 'widget-updated' event when saving a widget from
     * the widgets admin screen and also via the 'widget-synced' event when making
     * a change to a widget in the customizer.
     *
     * @alias wp.customHtmlWidgets.handleWidgetUpdated
     *
     * @param {jQuery.Event} event - Event.
     * @param {jQuery}       widgetContainer - Widget container element.
     * @return {void}
     */
    component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) {
        var widgetForm, widgetId, widgetControl, idBase;
        widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );

        idBase = widgetForm.find( '> .id_base' ).val();
        if ( -1 === component.idBases.indexOf( idBase ) ) {
            return;
        }

        widgetId = widgetForm.find( '> .widget-id' ).val();
        widgetControl = component.widgetControls[ widgetId ];
        if ( ! widgetControl ) {
            return;
        }

        widgetControl.updateFields();
    };

    /**
     * Initialize functionality.
     *
     * This function exists to prevent the JS file from having to boot itself.
     * When WordPress enqueues this script, it should have an inline script
     * attached which calls wp.textWidgets.init().
     *
     * @alias wp.customHtmlWidgets.init
     *
     * @param {Object} settings - Options for code editor, exported from PHP.
     *
     * @return {void}
     */
    component.init = function init( settings ) {
        var $document = $( document );
        _.extend( component.codeEditorSettings, settings );

        $document.on( 'widget-added', component.handleWidgetAdded );
        $document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );

        /*
         * Manually trigger widget-added events for media widgets on the admin
         * screen once they are expanded. The widget-added event is not triggered
         * for each pre-existing widget on the widgets admin screen like it is
         * on the customizer. Likewise, the customizer only triggers widget-added
         * when the widget is expanded to just-in-time construct the widget form
         * when it is actually going to be displayed. So the following implements
         * the same for the widgets admin screen, to invoke the widget-added
         * handler when a pre-existing media widget is expanded.
         */
        $( function initializeExistingWidgetContainers() {
            var widgetContainers;
            if ( 'widgets' !== window.pagenow ) {
                return;
            }
            widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' );
            widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() {
                var widgetContainer = $( this );
                component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer );
            });

            // Accessibility mode.
            if ( document.readyState === 'complete' ) {
                // Page is fully loaded.
                component.setupAccessibleMode();
            } else {
                // Page is still loading.
                $( window ).on( 'load', function() {
                    component.setupAccessibleMode();
                });
            }
        });
    };

    return component;
})( jQuery );