WordPress/wp-admin/js/widgets/text-widgets.js
Weston Ruter 40ebb188cd Widgets: Extend the Text widget with TinyMCE.
Introduces rich text formatting: bold, italic, lists, links.

Props westonruter, azaozz, timmydcrawford, obenland, melchoyce.
See #35760.
Fixes #35243.

Built from https://develop.svn.wordpress.org/trunk@40631


git-svn-id: http://core.svn.wordpress.org/trunk@40492 1a063a9b-81f0-0310-95a4-ce76da25c4cd
2017-05-11 18:55:43 +00:00

327 lines
11 KiB
JavaScript

/* global tinymce, switchEditors */
/* eslint consistent-this: [ "error", "control" ] */
wp.textWidgets = ( function( $ ) {
'use strict';
var component = {};
/**
* Text widget control.
*
* @class TextWidgetControl
* @constructor
* @abstract
*/
component.TextWidgetControl = Backbone.View.extend({
/**
* View events.
*
* @type {Object}
*/
events: {},
/**
* Initialize.
*
* @param {Object} options - Options.
* @param {Backbone.Model} options.model - Model.
* @param {jQuery} options.el - Control container element.
* @returns {void}
*/
initialize: function initialize( options ) {
var control = this;
if ( ! options.el ) {
throw new Error( 'Missing options.el' );
}
Backbone.View.prototype.initialize.call( control, options );
/*
* 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.
*/
control.fieldContainer = $( '<div class="text-widget-fields"></div>' );
control.fieldContainer.html( wp.template( 'widget-text-control-fields' ) );
control.widgetContentContainer = control.$el.find( '.widget-content:first' );
control.widgetContentContainer.before( control.fieldContainer );
control.fields = {
title: control.fieldContainer.find( '.title' ),
text: control.fieldContainer.find( '.text' )
};
// 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.widgetContentContainer.find( 'input[type=hidden].' + fieldName );
if ( syncInput.val() !== $( this ).val() ) {
syncInput.val( $( this ).val() );
syncInput.trigger( 'change' );
}
});
// Note that syncInput cannot be re-used because it will be destroyed with each widget-updated event.
fieldInput.val( control.widgetContentContainer.find( 'input[type=hidden].' + 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.
*
* @returns {void}
*/
updateFields: function updateFields() {
var control = this, syncInput;
if ( ! control.fields.title.is( document.activeElement ) ) {
syncInput = control.widgetContentContainer.find( 'input[type=hidden].title' );
control.fields.title.val( syncInput.val() );
}
syncInput = control.widgetContentContainer.find( 'input[type=hidden].text' );
if ( control.fields.text.is( ':visible' ) ) {
if ( ! control.fields.text.is( document.activeElement ) ) {
control.fields.text.val( syncInput.val() );
}
} else if ( control.editor && ! control.editorFocused && syncInput.val() !== control.fields.text.val() ) {
control.editor.setContent( wp.editor.autop( syncInput.val() ) );
}
},
/**
* Initialize editor.
*
* @returns {void}
*/
initializeEditor: function initializeEditor() {
var control = this, changeDebounceDelay = 1000, id, textarea, restoreTextMode = false;
textarea = control.fields.text;
id = textarea.attr( 'id' );
/**
* Build (or re-build) the visual editor.
*
* @returns {void}
*/
function buildEditor() {
var editor, triggerChangeIfDirty, onInit;
// Abort building if the textarea is gone, likely due to the widget having been deleted entirely.
if ( ! document.getElementById( id ) ) {
return;
}
// Destroy any existing editor so that it can be re-initialized after a widget-updated event.
if ( tinymce.get( id ) ) {
restoreTextMode = tinymce.get( id ).isHidden();
wp.editor.remove( id );
}
wp.editor.initialize( id, {
tinymce: {
wpautop: true
},
quicktags: true
} );
editor = window.tinymce.get( id );
if ( ! editor ) {
throw new Error( 'Failed to initialize editor' );
}
onInit = function() {
// When a widget is moved in the DOM the dynamically-created TinyMCE iframe will be destroyed and has to be re-built.
$( editor.getWin() ).on( 'unload', function() {
_.defer( buildEditor );
});
// If a prior mce instance was replaced, and it was in text mode, toggle to text mode.
if ( restoreTextMode ) {
switchEditors.go( id, 'toggle' );
}
};
if ( editor.initialized ) {
onInit();
} else {
editor.on( 'init', onInit );
}
control.editorFocused = false;
triggerChangeIfDirty = function() {
var updateWidgetBuffer = 300; // See wp.customize.Widgets.WidgetControl._setupUpdateUI() which uses 250ms for updateWidgetDebounced.
if ( editor.isDirty() ) {
/*
* Account for race condition in customizer where user clicks Save & Publish while
* focus was just previously given to to the editor. Since updates to the editor
* are debounced at 1 second and since widget input changes are only synced to
* settings after 250ms, the customizer needs to be put into the processing
* state during the time between the change event is triggered and updateWidget
* logic starts. Note that the debounced update-widget request should be able
* to be removed with the removal of the update-widget request entirely once
* widgets are able to mutate their own instance props directly in JS without
* having to make server round-trips to call the respective WP_Widget::update()
* callbacks. See <https://core.trac.wordpress.org/ticket/33507>.
*/
if ( wp.customize ) {
wp.customize.state( 'processing' ).set( wp.customize.state( 'processing' ).get() + 1 );
_.delay( function() {
wp.customize.state( 'processing' ).set( wp.customize.state( 'processing' ).get() - 1 );
}, updateWidgetBuffer );
}
editor.save();
textarea.trigger( 'change' );
}
};
editor.on( 'focus', function() {
control.editorFocused = true;
} );
editor.on( 'NodeChange', _.debounce( triggerChangeIfDirty, changeDebounceDelay ) );
editor.on( 'blur', function() {
control.editorFocused = false;
triggerChangeIfDirty();
} );
control.editor = editor;
}
buildEditor();
}
});
/**
* Mapping of widget ID to instances of TextWidgetControl subclasses.
*
* @type {Object.<string, wp.textWidgets.TextWidgetControl>}
*/
component.widgetControls = {};
/**
* Handle widget being added or initialized for the first time at the widget-added event.
*
* @param {jQuery.Event} event - Event.
* @param {jQuery} widgetContainer - Widget container element.
* @returns {void}
*/
component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
var widgetForm, idBase, widgetControl, widgetId, animatedCheckDelay = 50, widgetInside, renderWhenAnimationDone;
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 ( 'text' !== idBase ) {
return;
}
// Prevent initializing already-added widgets.
widgetId = widgetForm.find( '> .widget-id' ).val();
if ( component.widgetControls[ widgetId ] ) {
return;
}
widgetControl = new component.TextWidgetControl({
el: widgetContainer
});
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 an iframe can be embedded
* with TinyMCE being able to set contenteditable on it.
*/
widgetInside = widgetContainer.parent();
renderWhenAnimationDone = function() {
if ( widgetInside.is( ':animated' ) ) {
setTimeout( renderWhenAnimationDone, animatedCheckDelay );
} else {
widgetControl.initializeEditor();
}
};
renderWhenAnimationDone();
};
/**
* 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.
*
* @param {jQuery.Event} event - Event.
* @param {jQuery} widgetContainer - Widget container element.
* @returns {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 ( 'text' !== 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().
*
* @returns {void}
*/
component.init = function init() {
var $document = $( document );
$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 );
});
});
};
return component;
})( jQuery );