home *** CD-ROM | disk | FTP | other *** search
/ HTML Examples / WP.iso / wordpress / wp-admin / js / widgets / text-widgets.js < prev    next >
Encoding:
JavaScript  |  2017-09-09  |  17.3 KB  |  536 lines

  1. /* global tinymce, switchEditors */
  2. /* eslint consistent-this: [ "error", "control" ] */
  3. wp.textWidgets = ( function( $ ) {
  4.     'use strict';
  5.  
  6.     var component = {
  7.         dismissedPointers: [],
  8.         idBases: [ 'text' ]
  9.     };
  10.  
  11.     /**
  12.      * Text widget control.
  13.      *
  14.      * @class TextWidgetControl
  15.      * @constructor
  16.      * @abstract
  17.      */
  18.     component.TextWidgetControl = Backbone.View.extend({
  19.  
  20.         /**
  21.          * View events.
  22.          *
  23.          * @type {Object}
  24.          */
  25.         events: {},
  26.  
  27.         /**
  28.          * Initialize.
  29.          *
  30.          * @param {Object} options - Options.
  31.          * @param {jQuery} options.el - Control field container element.
  32.          * @param {jQuery} options.syncContainer - Container element where fields are synced for the server.
  33.          * @returns {void}
  34.          */
  35.         initialize: function initialize( options ) {
  36.             var control = this;
  37.  
  38.             if ( ! options.el ) {
  39.                 throw new Error( 'Missing options.el' );
  40.             }
  41.             if ( ! options.syncContainer ) {
  42.                 throw new Error( 'Missing options.syncContainer' );
  43.             }
  44.  
  45.             Backbone.View.prototype.initialize.call( control, options );
  46.             control.syncContainer = options.syncContainer;
  47.  
  48.             control.$el.addClass( 'text-widget-fields' );
  49.             control.$el.html( wp.template( 'widget-text-control-fields' ) );
  50.  
  51.             control.customHtmlWidgetPointer = control.$el.find( '.wp-pointer.custom-html-widget-pointer' );
  52.             if ( control.customHtmlWidgetPointer.length ) {
  53.                 control.customHtmlWidgetPointer.find( '.close' ).on( 'click', function( event ) {
  54.                     event.preventDefault();
  55.                     control.customHtmlWidgetPointer.hide();
  56.                     $( '#' + control.fields.text.attr( 'id' ) + '-html' ).focus();
  57.                     control.dismissPointers( [ 'text_widget_custom_html' ] );
  58.                 });
  59.                 control.customHtmlWidgetPointer.find( '.add-widget' ).on( 'click', function( event ) {
  60.                     event.preventDefault();
  61.                     control.customHtmlWidgetPointer.hide();
  62.                     control.openAvailableWidgetsPanel();
  63.                 });
  64.             }
  65.  
  66.             control.pasteHtmlPointer = control.$el.find( '.wp-pointer.paste-html-pointer' );
  67.             if ( control.pasteHtmlPointer.length ) {
  68.                 control.pasteHtmlPointer.find( '.close' ).on( 'click', function( event ) {
  69.                     event.preventDefault();
  70.                     control.pasteHtmlPointer.hide();
  71.                     control.editor.focus();
  72.                     control.dismissPointers( [ 'text_widget_custom_html', 'text_widget_paste_html' ] );
  73.                 });
  74.             }
  75.  
  76.             control.fields = {
  77.                 title: control.$el.find( '.title' ),
  78.                 text: control.$el.find( '.text' )
  79.             };
  80.  
  81.             // Sync input fields to hidden sync fields which actually get sent to the server.
  82.             _.each( control.fields, function( fieldInput, fieldName ) {
  83.                 fieldInput.on( 'input change', function updateSyncField() {
  84.                     var syncInput = control.syncContainer.find( '.sync-input.' + fieldName );
  85.                     if ( syncInput.val() !== fieldInput.val() ) {
  86.                         syncInput.val( fieldInput.val() );
  87.                         syncInput.trigger( 'change' );
  88.                     }
  89.                 });
  90.  
  91.                 // Note that syncInput cannot be re-used because it will be destroyed with each widget-updated event.
  92.                 fieldInput.val( control.syncContainer.find( '.sync-input.' + fieldName ).val() );
  93.             });
  94.         },
  95.  
  96.         /**
  97.          * Dismiss pointers for Custom HTML widget.
  98.          *
  99.          * @since 4.8.1
  100.          *
  101.          * @param {Array} pointers Pointer IDs to dismiss.
  102.          * @returns {void}
  103.          */
  104.         dismissPointers: function dismissPointers( pointers ) {
  105.             _.each( pointers, function( pointer ) {
  106.                 wp.ajax.post( 'dismiss-wp-pointer', {
  107.                     pointer: pointer
  108.                 });
  109.                 component.dismissedPointers.push( pointer );
  110.             });
  111.         },
  112.  
  113.         /**
  114.          * Open available widgets panel.
  115.          *
  116.          * @since 4.8.1
  117.          * @returns {void}
  118.          */
  119.         openAvailableWidgetsPanel: function openAvailableWidgetsPanel() {
  120.             var sidebarControl;
  121.             wp.customize.section.each( function( section ) {
  122.                 if ( section.extended( wp.customize.Widgets.SidebarSection ) && section.expanded() ) {
  123.                     sidebarControl = wp.customize.control( 'sidebars_widgets[' + section.params.sidebarId + ']' );
  124.                 }
  125.             });
  126.             if ( ! sidebarControl ) {
  127.                 return;
  128.             }
  129.             setTimeout( function() { // Timeout to prevent click event from causing panel to immediately collapse.
  130.                 wp.customize.Widgets.availableWidgetsPanel.open( sidebarControl );
  131.                 wp.customize.Widgets.availableWidgetsPanel.$search.val( 'HTML' ).trigger( 'keyup' );
  132.             });
  133.         },
  134.  
  135.         /**
  136.          * Update input fields from the sync fields.
  137.          *
  138.          * This function is called at the widget-updated and widget-synced events.
  139.          * A field will only be updated if it is not currently focused, to avoid
  140.          * overwriting content that the user is entering.
  141.          *
  142.          * @returns {void}
  143.          */
  144.         updateFields: function updateFields() {
  145.             var control = this, syncInput;
  146.  
  147.             if ( ! control.fields.title.is( document.activeElement ) ) {
  148.                 syncInput = control.syncContainer.find( '.sync-input.title' );
  149.                 control.fields.title.val( syncInput.val() );
  150.             }
  151.  
  152.             syncInput = control.syncContainer.find( '.sync-input.text' );
  153.             if ( control.fields.text.is( ':visible' ) ) {
  154.                 if ( ! control.fields.text.is( document.activeElement ) ) {
  155.                     control.fields.text.val( syncInput.val() );
  156.                 }
  157.             } else if ( control.editor && ! control.editorFocused && syncInput.val() !== control.fields.text.val() ) {
  158.                 control.editor.setContent( wp.editor.autop( syncInput.val() ) );
  159.             }
  160.         },
  161.  
  162.         /**
  163.          * Initialize editor.
  164.          *
  165.          * @returns {void}
  166.          */
  167.         initializeEditor: function initializeEditor() {
  168.             var control = this, changeDebounceDelay = 1000, id, textarea, triggerChangeIfDirty, restoreTextMode = false, needsTextareaChangeTrigger = false, previousValue;
  169.             textarea = control.fields.text;
  170.             id = textarea.attr( 'id' );
  171.             previousValue = textarea.val();
  172.  
  173.             /**
  174.              * Trigger change if dirty.
  175.              *
  176.              * @returns {void}
  177.              */
  178.             triggerChangeIfDirty = function() {
  179.                 var updateWidgetBuffer = 300; // See wp.customize.Widgets.WidgetControl._setupUpdateUI() which uses 250ms for updateWidgetDebounced.
  180.                 if ( control.editor.isDirty() ) {
  181.  
  182.                     /*
  183.                      * Account for race condition in customizer where user clicks Save & Publish while
  184.                      * focus was just previously given to the editor. Since updates to the editor
  185.                      * are debounced at 1 second and since widget input changes are only synced to
  186.                      * settings after 250ms, the customizer needs to be put into the processing
  187.                      * state during the time between the change event is triggered and updateWidget
  188.                      * logic starts. Note that the debounced update-widget request should be able
  189.                      * to be removed with the removal of the update-widget request entirely once
  190.                      * widgets are able to mutate their own instance props directly in JS without
  191.                      * having to make server round-trips to call the respective WP_Widget::update()
  192.                      * callbacks. See <https://core.trac.wordpress.org/ticket/33507>.
  193.                      */
  194.                     if ( wp.customize && wp.customize.state ) {
  195.                         wp.customize.state( 'processing' ).set( wp.customize.state( 'processing' ).get() + 1 );
  196.                         _.delay( function() {
  197.                             wp.customize.state( 'processing' ).set( wp.customize.state( 'processing' ).get() - 1 );
  198.                         }, updateWidgetBuffer );
  199.                     }
  200.  
  201.                     if ( ! control.editor.isHidden() ) {
  202.                         control.editor.save();
  203.                     }
  204.                 }
  205.  
  206.                 // Trigger change on textarea when it has changed so the widget can enter a dirty state.
  207.                 if ( needsTextareaChangeTrigger && previousValue !== textarea.val() ) {
  208.                     textarea.trigger( 'change' );
  209.                     needsTextareaChangeTrigger = false;
  210.                     previousValue = textarea.val();
  211.                 }
  212.             };
  213.  
  214.             // Just-in-time force-update the hidden input fields.
  215.             control.syncContainer.closest( '.widget' ).find( '[name=savewidget]:first' ).on( 'click', function onClickSaveButton() {
  216.                 triggerChangeIfDirty();
  217.             });
  218.  
  219.             /**
  220.              * Build (or re-build) the visual editor.
  221.              *
  222.              * @returns {void}
  223.              */
  224.             function buildEditor() {
  225.                 var editor, onInit, showPointerElement;
  226.  
  227.                 // Abort building if the textarea is gone, likely due to the widget having been deleted entirely.
  228.                 if ( ! document.getElementById( id ) ) {
  229.                     return;
  230.                 }
  231.  
  232.                 // The user has disabled TinyMCE.
  233.                 if ( typeof window.tinymce === 'undefined' ) {
  234.                     wp.editor.initialize( id, {
  235.                         quicktags: true,
  236.                         mediaButtons: true
  237.                     });
  238.  
  239.                     return;
  240.                 }
  241.  
  242.                 // Destroy any existing editor so that it can be re-initialized after a widget-updated event.
  243.                 if ( tinymce.get( id ) ) {
  244.                     restoreTextMode = tinymce.get( id ).isHidden();
  245.                     wp.editor.remove( id );
  246.                 }
  247.  
  248.                 // Add or enable the `wpview` plugin.
  249.                 $( document ).one( 'wp-before-tinymce-init.text-widget-init', function( event, init ) {
  250.                     // If somebody has removed all plugins, they must have a good reason.
  251.                     // Keep it that way.
  252.                     if ( ! init.plugins ) {
  253.                         return;
  254.                     } else if ( ! /\bwpview\b/.test( init.plugins ) ) {
  255.                         init.plugins += ',wpview';
  256.                     }
  257.                 } );
  258.  
  259.                 wp.editor.initialize( id, {
  260.                     tinymce: {
  261.                         wpautop: true
  262.                     },
  263.                     quicktags: true,
  264.                     mediaButtons: true
  265.                 });
  266.  
  267.                 /**
  268.                  * Show a pointer, focus on dismiss, and speak the contents for a11y.
  269.                  *
  270.                  * @param {jQuery} pointerElement Pointer element.
  271.                  * @returns {void}
  272.                  */
  273.                 showPointerElement = function( pointerElement ) {
  274.                     pointerElement.show();
  275.                     pointerElement.find( '.close' ).focus();
  276.                     wp.a11y.speak( pointerElement.find( 'h3, p' ).map( function() {
  277.                         return $( this ).text();
  278.                     } ).get().join( '\n\n' ) );
  279.                 };
  280.  
  281.                 editor = window.tinymce.get( id );
  282.                 if ( ! editor ) {
  283.                     throw new Error( 'Failed to initialize editor' );
  284.                 }
  285.                 onInit = function() {
  286.  
  287.                     // When a widget is moved in the DOM the dynamically-created TinyMCE iframe will be destroyed and has to be re-built.
  288.                     $( editor.getWin() ).on( 'unload', function() {
  289.                         _.defer( buildEditor );
  290.                     });
  291.  
  292.                     // If a prior mce instance was replaced, and it was in text mode, toggle to text mode.
  293.                     if ( restoreTextMode ) {
  294.                         switchEditors.go( id, 'html' );
  295.                     }
  296.  
  297.                     // Show the pointer.
  298.                     $( '#' + id + '-html' ).on( 'click', function() {
  299.                         control.pasteHtmlPointer.hide(); // Hide the HTML pasting pointer.
  300.  
  301.                         if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_custom_html' ) ) {
  302.                             return;
  303.                         }
  304.                         showPointerElement( control.customHtmlWidgetPointer );
  305.                     });
  306.  
  307.                     // Hide the pointer when switching tabs.
  308.                     $( '#' + id + '-tmce' ).on( 'click', function() {
  309.                         control.customHtmlWidgetPointer.hide();
  310.                     });
  311.  
  312.                     // Show pointer when pasting HTML.
  313.                     editor.on( 'pastepreprocess', function( event ) {
  314.                         var content = event.content;
  315.                         if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_paste_html' ) || ! content || ! /<\w+.*?>/.test( content ) ) {
  316.                             return;
  317.                         }
  318.  
  319.                         // Show the pointer after a slight delay so the user sees what they pasted.
  320.                         _.delay( function() {
  321.                             showPointerElement( control.pasteHtmlPointer );
  322.                         }, 250 );
  323.                     });
  324.                 };
  325.  
  326.                 if ( editor.initialized ) {
  327.                     onInit();
  328.                 } else {
  329.                     editor.on( 'init', onInit );
  330.                 }
  331.  
  332.                 control.editorFocused = false;
  333.  
  334.                 editor.on( 'focus', function onEditorFocus() {
  335.                     control.editorFocused = true;
  336.                 });
  337.                 editor.on( 'paste', function onEditorPaste() {
  338.                     editor.setDirty( true ); // Because pasting doesn't currently set the dirty state.
  339.                     triggerChangeIfDirty();
  340.                 });
  341.                 editor.on( 'NodeChange', function onNodeChange() {
  342.                     needsTextareaChangeTrigger = true;
  343.                 });
  344.                 editor.on( 'NodeChange', _.debounce( triggerChangeIfDirty, changeDebounceDelay ) );
  345.                 editor.on( 'blur hide', function onEditorBlur() {
  346.                     control.editorFocused = false;
  347.                     triggerChangeIfDirty();
  348.                 });
  349.  
  350.                 control.editor = editor;
  351.             }
  352.  
  353.             buildEditor();
  354.         }
  355.     });
  356.  
  357.     /**
  358.      * Mapping of widget ID to instances of TextWidgetControl subclasses.
  359.      *
  360.      * @type {Object.<string, wp.textWidgets.TextWidgetControl>}
  361.      */
  362.     component.widgetControls = {};
  363.  
  364.     /**
  365.      * Handle widget being added or initialized for the first time at the widget-added event.
  366.      *
  367.      * @param {jQuery.Event} event - Event.
  368.      * @param {jQuery}       widgetContainer - Widget container element.
  369.      * @returns {void}
  370.      */
  371.     component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
  372.         var widgetForm, idBase, widgetControl, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone, fieldContainer, syncContainer;
  373.         widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.
  374.  
  375.         idBase = widgetForm.find( '> .id_base' ).val();
  376.         if ( -1 === component.idBases.indexOf( idBase ) ) {
  377.             return;
  378.         }
  379.  
  380.         // Prevent initializing already-added widgets.
  381.         widgetId = widgetForm.find( '.widget-id' ).val();
  382.         if ( component.widgetControls[ widgetId ] ) {
  383.             return;
  384.         }
  385.  
  386.         // Bypass using TinyMCE when widget is in legacy mode.
  387.         if ( ! widgetForm.find( '.visual' ).val() ) {
  388.             return;
  389.         }
  390.  
  391.         /*
  392.          * Create a container element for the widget control fields.
  393.          * This is inserted into the DOM immediately before the .widget-content
  394.          * element because the contents of this element are essentially "managed"
  395.          * by PHP, where each widget update cause the entire element to be emptied
  396.          * and replaced with the rendered output of WP_Widget::form() which is
  397.          * sent back in Ajax request made to save/update the widget instance.
  398.          * To prevent a "flash of replaced DOM elements and re-initialized JS
  399.          * components", the JS template is rendered outside of the normal form
  400.          * container.
  401.          */
  402.         fieldContainer = $( '<div></div>' );
  403.         syncContainer = widgetContainer.find( '.widget-content:first' );
  404.         syncContainer.before( fieldContainer );
  405.  
  406.         widgetControl = new component.TextWidgetControl({
  407.             el: fieldContainer,
  408.             syncContainer: syncContainer
  409.         });
  410.  
  411.         component.widgetControls[ widgetId ] = widgetControl;
  412.  
  413.         /*
  414.          * Render the widget once the widget parent's container finishes animating,
  415.          * as the widget-added event fires with a slideDown of the container.
  416.          * This ensures that the textarea is visible and an iframe can be embedded
  417.          * with TinyMCE being able to set contenteditable on it.
  418.          */
  419.         renderWhenAnimationDone = function() {
  420.             if ( ! widgetContainer.hasClass( 'open' ) ) {
  421.                 setTimeout( renderWhenAnimationDone, animatedCheckDelay );
  422.             } else {
  423.                 widgetControl.initializeEditor();
  424.             }
  425.         };
  426.         renderWhenAnimationDone();
  427.     };
  428.  
  429.     /**
  430.      * Setup widget in accessibility mode.
  431.      *
  432.      * @returns {void}
  433.      */
  434.     component.setupAccessibleMode = function setupAccessibleMode() {
  435.         var widgetForm, idBase, widgetControl, fieldContainer, syncContainer;
  436.         widgetForm = $( '.editwidget > form' );
  437.         if ( 0 === widgetForm.length ) {
  438.             return;
  439.         }
  440.  
  441.         idBase = widgetForm.find( '> .widget-control-actions > .id_base' ).val();
  442.         if ( -1 === component.idBases.indexOf( idBase ) ) {
  443.             return;
  444.         }
  445.  
  446.         // Bypass using TinyMCE when widget is in legacy mode.
  447.         if ( ! widgetForm.find( '.visual' ).val() ) {
  448.             return;
  449.         }
  450.  
  451.         fieldContainer = $( '<div></div>' );
  452.         syncContainer = widgetForm.find( '> .widget-inside' );
  453.         syncContainer.before( fieldContainer );
  454.  
  455.         widgetControl = new component.TextWidgetControl({
  456.             el: fieldContainer,
  457.             syncContainer: syncContainer
  458.         });
  459.  
  460.         widgetControl.initializeEditor();
  461.     };
  462.  
  463.     /**
  464.      * Sync widget instance data sanitized from server back onto widget model.
  465.      *
  466.      * This gets called via the 'widget-updated' event when saving a widget from
  467.      * the widgets admin screen and also via the 'widget-synced' event when making
  468.      * a change to a widget in the customizer.
  469.      *
  470.      * @param {jQuery.Event} event - Event.
  471.      * @param {jQuery}       widgetContainer - Widget container element.
  472.      * @returns {void}
  473.      */
  474.     component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) {
  475.         var widgetForm, widgetId, widgetControl, idBase;
  476.         widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
  477.  
  478.         idBase = widgetForm.find( '> .id_base' ).val();
  479.         if ( -1 === component.idBases.indexOf( idBase ) ) {
  480.             return;
  481.         }
  482.  
  483.         widgetId = widgetForm.find( '> .widget-id' ).val();
  484.         widgetControl = component.widgetControls[ widgetId ];
  485.         if ( ! widgetControl ) {
  486.             return;
  487.         }
  488.  
  489.         widgetControl.updateFields();
  490.     };
  491.  
  492.     /**
  493.      * Initialize functionality.
  494.      *
  495.      * This function exists to prevent the JS file from having to boot itself.
  496.      * When WordPress enqueues this script, it should have an inline script
  497.      * attached which calls wp.textWidgets.init().
  498.      *
  499.      * @returns {void}
  500.      */
  501.     component.init = function init() {
  502.         var $document = $( document );
  503.         $document.on( 'widget-added', component.handleWidgetAdded );
  504.         $document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );
  505.  
  506.         /*
  507.          * Manually trigger widget-added events for media widgets on the admin
  508.          * screen once they are expanded. The widget-added event is not triggered
  509.          * for each pre-existing widget on the widgets admin screen like it is
  510.          * on the customizer. Likewise, the customizer only triggers widget-added
  511.          * when the widget is expanded to just-in-time construct the widget form
  512.          * when it is actually going to be displayed. So the following implements
  513.          * the same for the widgets admin screen, to invoke the widget-added
  514.          * handler when a pre-existing media widget is expanded.
  515.          */
  516.         $( function initializeExistingWidgetContainers() {
  517.             var widgetContainers;
  518.             if ( 'widgets' !== window.pagenow ) {
  519.                 return;
  520.             }
  521.             widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' );
  522.             widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() {
  523.                 var widgetContainer = $( this );
  524.                 component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer );
  525.             });
  526.  
  527.             // Accessibility mode.
  528.             $( window ).on( 'load', function() {
  529.                 component.setupAccessibleMode();
  530.             });
  531.         });
  532.     };
  533.  
  534.     return component;
  535. })( jQuery );
  536.