From 5eb62b94ad330e85f0325f68873f3f5335c7d7ff Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 May 2017 18:54:24 +0000 Subject: [PATCH] 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. git-svn-id: https://develop.svn.wordpress.org/trunk@40631 602fd350-edb4-49c9-b593-d223f7449a82 --- Gruntfile.js | 2 +- src/wp-admin/css/customize-widgets.css | 15 + src/wp-admin/js/widgets/text-widgets.js | 326 ++++++++++++++++++ src/wp-includes/default-filters.php | 6 +- src/wp-includes/script-loader.php | 2 + .../widgets/class-wp-widget-text.php | 112 +++++- tests/phpunit/tests/widgets/text-widget.php | 249 +++++++++++++ 7 files changed, 697 insertions(+), 15 deletions(-) create mode 100644 src/wp-admin/js/widgets/text-widgets.js create mode 100644 tests/phpunit/tests/widgets/text-widget.php diff --git a/Gruntfile.js b/Gruntfile.js index 14e558f2d1..37848c1449 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -456,7 +456,7 @@ module.exports = function(grunt) { dest: BUILD_DIR, ext: '.min.js', src: [ - 'wp-admin/js/*.js', + 'wp-admin/js/**/*.js', 'wp-includes/js/*.js', 'wp-includes/js/mediaelement/wp-mediaelement.js', 'wp-includes/js/mediaelement/wp-playlist.js', diff --git a/src/wp-admin/css/customize-widgets.css b/src/wp-admin/css/customize-widgets.css index bdb58982fe..0a9fbf5869 100644 --- a/src/wp-admin/css/customize-widgets.css +++ b/src/wp-admin/css/customize-widgets.css @@ -213,6 +213,21 @@ display: block; } +/* Text Widget */ +.wp-customizer div.mce-inline-toolbar-grp, +.wp-customizer div.mce-tooltip { + z-index: 500100 !important; +} +.wp-customizer .ui-autocomplete.wplink-autocomplete { + z-index: 500110; /* originally 100110, but z-index of .wp-full-overlay is 500000 */ +} +.wp-customizer #wp-link-backdrop { + z-index: 500100; /* originally 100100, but z-index of .wp-full-overlay is 500000 */ +} +.wp-customizer #wp-link-wrap { + z-index: 500105; /* originally 100105, but z-index of .wp-full-overlay is 500000 */ +} + /** * Styles for new widget addition panel */ diff --git a/src/wp-admin/js/widgets/text-widgets.js b/src/wp-admin/js/widgets/text-widgets.js new file mode 100644 index 0000000000..2d3a5b812c --- /dev/null +++ b/src/wp-admin/js/widgets/text-widgets.js @@ -0,0 +1,326 @@ +/* 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 = $( '
' ); + 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 . + */ + 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.} + */ + 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 ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index e6050774c6..ef412e9e6a 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -164,7 +164,11 @@ add_filter( 'list_cats', 'wptexturize' ); add_filter( 'wp_sprintf', 'wp_sprintf_l', 10, 2 ); -add_filter( 'widget_text', 'balanceTags' ); +add_filter( 'widget_text', 'balanceTags' ); +add_filter( 'widget_text_content', 'capital_P_dangit', 11 ); +add_filter( 'widget_text_content', 'wptexturize' ); +add_filter( 'widget_text_content', 'convert_smilies', 20 ); +add_filter( 'widget_text_content', 'wpautop' ); add_filter( 'date_i18n', 'wp_maybe_decline_date' ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 43209721f5..344bc4559b 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -602,6 +602,8 @@ function wp_default_scripts( &$scripts ) { $scripts->add( 'admin-gallery', "/wp-admin/js/gallery$suffix.js", array( 'jquery-ui-sortable' ) ); $scripts->add( 'admin-widgets', "/wp-admin/js/widgets$suffix.js", array( 'jquery-ui-sortable', 'jquery-ui-draggable', 'jquery-ui-droppable' ), false, 1 ); + $scripts->add( 'text-widgets', "/wp-admin/js/widgets/text-widgets$suffix.js", array( 'jquery', 'backbone', 'editor', 'wp-util' ) ); + $scripts->add_inline_script( 'text-widgets', 'wp.textWidgets.init();', 'after' ); $scripts->add( 'theme', "/wp-admin/js/theme$suffix.js", array( 'wp-backbone', 'wp-a11y' ), false, 1 ); diff --git a/src/wp-includes/widgets/class-wp-widget-text.php b/src/wp-includes/widgets/class-wp-widget-text.php index a379fd719d..434a123f90 100644 --- a/src/wp-includes/widgets/class-wp-widget-text.php +++ b/src/wp-includes/widgets/class-wp-widget-text.php @@ -28,10 +28,30 @@ class WP_Widget_Text extends WP_Widget { 'description' => __( 'Arbitrary text or HTML.' ), 'customize_selective_refresh' => true, ); - $control_ops = array( 'width' => 400, 'height' => 350 ); + $control_ops = array( + 'width' => 400, + 'height' => 350, + ); parent::__construct( 'text', __( 'Text' ), $widget_ops, $control_ops ); } + /** + * Add hooks for enqueueing assets when registering all widget instances of this widget class. + * + * @since 4.8.0 + * @access public + */ + public function _register() { + + // Note that the widgets component in the customizer will also do the 'admin_print_scripts-widgets.php' action in WP_Customize_Widgets::print_scripts(). + add_action( 'admin_print_scripts-widgets.php', array( $this, 'enqueue_admin_scripts' ) ); + + // Note that the widgets component in the customizer will also do the 'admin_footer-widgets.php' action in WP_Customize_Widgets::print_footer_scripts(). + add_action( 'admin_footer-widgets.php', array( $this, 'render_control_template_scripts' ) ); + + parent::_register(); + } + /** * Outputs the content for the current Text widget instance. * @@ -61,11 +81,34 @@ class WP_Widget_Text extends WP_Widget { */ $text = apply_filters( 'widget_text', $widget_text, $instance, $this ); + if ( isset( $instance['filter'] ) ) { + if ( 'content' === $instance['filter'] ) { + + /** + * Filters the content of the Text widget to apply changes expected from the visual (TinyMCE) editor. + * + * By default a subset of the_content filters are applied, including wpautop and wptexturize. + * + * @since 4.8.0 + * + * @param string $widget_text The widget content. + * @param array $instance Array of settings for the current widget. + * @param WP_Widget_Text $this Current Text widget instance. + */ + $text = apply_filters( 'widget_text_content', $widget_text, $instance, $this ); + + } elseif ( $instance['filter'] ) { + $text = wpautop( $text ); // Back-compat for instances prior to 4.8. + } + } + echo $args['before_widget']; if ( ! empty( $title ) ) { echo $args['before_title'] . $title . $args['after_title']; - } ?> -
+ } + + ?> +
'', 'text' => '' ) ); - $filter = isset( $instance['filter'] ) ? $instance['filter'] : 0; - $title = sanitize_text_field( $instance['title'] ); + $instance = wp_parse_args( + (array) $instance, + array( + 'title' => '', + 'text' => '', + ) + ); ?> -

-

+ + + -

- -

/> 

+ /** + * Render form template scripts. + * + * @since 4.8.0 + * @access public + */ + public function render_control_template_scripts() { + ?> + _register(); + + $this->assertEquals( 10, has_action( 'admin_print_scripts-widgets.php', array( $widget, 'enqueue_admin_scripts' ) ) ); + $this->assertEquals( 10, has_action( 'admin_footer-widgets.php', array( $widget, 'render_control_template_scripts' ) ) ); + } + + /** + * Test widget method. + * + * @covers WP_Widget_Text::widget + */ + function test_widget() { + $widget = new WP_Widget_Text(); + $text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n Praesent ut turpis consequat lorem volutpat bibendum vitae vitae ante."; + + $args = array( + 'before_title' => '

', + 'after_title' => "

\n", + 'before_widget' => '
', + 'after_widget' => "
\n", + ); + $instance = array( + 'title' => 'Foo', + 'text' => $text, + 'filter' => false, + ); + + add_filter( 'widget_text_content', array( $this, 'filter_widget_text_content' ), 10, 3 ); + add_filter( 'widget_text', array( $this, 'filter_widget_text' ), 10, 3 ); + + // Test with filter=false. + ob_start(); + $widget->widget( $args, $instance ); + $output = ob_get_clean(); + $this->assertNotContains( '

', $output ); + $this->assertNotContains( '
', $output ); + $this->assertEmpty( $this->widget_text_content_args ); + $this->assertNotEmpty( $this->widget_text_args ); + + // Test with filter=true. + $instance['filter'] = true; + ob_start(); + $widget->widget( $args, $instance ); + $output = ob_get_clean(); + $this->assertContains( '

', $output ); + $this->assertContains( '
', $output ); + $this->assertNotEmpty( $this->widget_text_args ); + $this->assertEquals( $instance['text'], $this->widget_text_args[0] ); + $this->assertEquals( $instance, $this->widget_text_args[1] ); + $this->assertEquals( $widget, $this->widget_text_args[2] ); + $this->assertEmpty( $this->widget_text_content_args ); + + // Test with filter=content, the upgraded widget. + $instance['filter'] = 'content'; + ob_start(); + $widget->widget( $args, $instance ); + $output = ob_get_clean(); + $this->assertContains( '

', $output ); + $this->assertContains( '
', $output ); + $this->assertCount( 3, $this->widget_text_args ); + $this->assertEquals( $instance['text'], $this->widget_text_args[0] ); + $this->assertEquals( $instance, $this->widget_text_args[1] ); + $this->assertEquals( $widget, $this->widget_text_args[2] ); + $this->assertCount( 3, $this->widget_text_content_args ); + $this->assertEquals( wpautop( $instance['text'] ), $this->widget_text_content_args[0] ); + $this->assertEquals( $instance, $this->widget_text_content_args[1] ); + $this->assertEquals( $widget, $this->widget_text_content_args[2] ); + } + + /** + * Filters the content of the Text widget. + * + * @param string $widget_text The widget content. + * @param array $instance Array of settings for the current widget. + * @param WP_Widget_Text $widget Current Text widget instance. + * @return string Widget text. + */ + function filter_widget_text( $widget_text, $instance, $widget ) { + $this->widget_text_args = func_get_args(); + + return $widget_text; + } + + /** + * Filters the content of the Text widget to apply changes expected from the visual (TinyMCE) editor. + * + * @param string $widget_text The widget content. + * @param array $instance Array of settings for the current widget. + * @param WP_Widget_Text $widget Current Text widget instance. + * @return string Widget content. + */ + function filter_widget_text_content( $widget_text, $instance, $widget ) { + $this->widget_text_content_args = func_get_args(); + + return $widget_text; + } + + /** + * Test update method. + * + * @covers WP_Widget_Text::update + */ + function test_update() { + $widget = new WP_Widget_Text(); + $instance = array( + 'title' => "The\nTitle", + 'text' => "The\n\nText", + 'filter' => false, + ); + + wp_set_current_user( $this->factory()->user->create( array( + 'role' => 'administrator', + ) ) ); + + // Should return valid instance. + $expected = array( + 'title' => sanitize_text_field( $instance['title'] ), + 'text' => $instance['text'], + 'filter' => 'content', + ); + $result = $widget->update( $instance, array() ); + $this->assertEquals( $result, $expected ); + $this->assertTrue( ! empty( $expected['filter'] ), 'Expected filter prop to be truthy, to handle case where 4.8 is downgraded to 4.7.' ); + + // Make sure KSES is applying as expected. + add_filter( 'map_meta_cap', array( $this, 'grant_unfiltered_html_cap' ), 10, 2 ); + $this->assertTrue( current_user_can( 'unfiltered_html' ) ); + $instance['text'] = ''; + $expected['text'] = $instance['text']; + $result = $widget->update( $instance, array() ); + $this->assertEquals( $result, $expected ); + + remove_filter( 'map_meta_cap', array( $this, 'grant_unfiltered_html_cap' ) ); + add_filter( 'map_meta_cap', array( $this, 'revoke_unfiltered_html_cap' ), 10, 2 ); + $this->assertFalse( current_user_can( 'unfiltered_html' ) ); + $instance['text'] = ''; + $expected['text'] = wp_kses_post( $instance['text'] ); + $result = $widget->update( $instance, array() ); + $this->assertEquals( $result, $expected ); + remove_filter( 'map_meta_cap', array( $this, 'revoke_unfiltered_html_cap' ), 10, 2 ); + } + + /** + * Grant unfiltered_html cap via map_meta_cap. + * + * @param array $caps Returns the user's actual capabilities. + * @param string $cap Capability name. + * @return array Caps. + */ + function grant_unfiltered_html_cap( $caps, $cap ) { + if ( 'unfiltered_html' === $cap ) { + $caps = array_diff( $caps, array( 'do_not_allow' ) ); + $caps[] = 'unfiltered_html'; + } + return $caps; + } + + /** + * Revoke unfiltered_html cap via map_meta_cap. + * + * @param array $caps Returns the user's actual capabilities. + * @param string $cap Capability name. + * @return array Caps. + */ + function revoke_unfiltered_html_cap( $caps, $cap ) { + if ( 'unfiltered_html' === $cap ) { + $caps = array_diff( $caps, array( 'unfiltered_html' ) ); + $caps[] = 'do_not_allow'; + } + return $caps; + } + + /** + * Test enqueue_admin_scripts method. + * + * @covers WP_Widget_Text::enqueue_admin_scripts + */ + function test_enqueue_admin_scripts() { + set_current_screen( 'widgets.php' ); + $widget = new WP_Widget_Text(); + $widget->enqueue_admin_scripts(); + + $this->assertTrue( wp_script_is( 'text-widgets' ) ); + } + + /** + * Test render_control_template_scripts method. + * + * @covers WP_Widget_Text::render_control_template_scripts + */ + function test_render_control_template_scripts() { + $widget = new WP_Widget_Text(); + + ob_start(); + $widget->render_control_template_scripts(); + $output = ob_get_clean(); + + $this->assertContains( '