From e85f291a79481f7de1ee2a65671d476d9523564b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 14 Jul 2017 17:08:20 +0000 Subject: [PATCH] Widgets: Add legacy mode for Text widget and add usage pointers to default visual mode. The Text widget in legacy mode omits TinyMCE and retains old behavior for matching pre-existing Text widgets. Usage pointers added to default visual mode appear when attempting to paste HTML code into the Visual tab and when clicking on the Text tab, informing users of the new Custom HTML widget. Props westonruter, melchoyce, gitlost for testing, obenland for testing, dougal for testing, afercia for testing. See #35243. Fixes #40951. git-svn-id: https://develop.svn.wordpress.org/trunk@41050 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/widgets.css | 23 ++ src/wp-admin/js/widgets/text-widgets.js | 122 +++++++- src/wp-includes/script-loader.php | 4 +- .../widgets/class-wp-widget-text.php | 214 ++++++++++++- tests/phpunit/tests/widgets/text-widget.php | 289 +++++++++++++++++- 5 files changed, 639 insertions(+), 13 deletions(-) diff --git a/src/wp-admin/css/widgets.css b/src/wp-admin/css/widgets.css index d06fc29f70..cf02c7626b 100644 --- a/src/wp-admin/css/widgets.css +++ b/src/wp-admin/css/widgets.css @@ -619,6 +619,29 @@ div#widgets-right .widget-top:hover, cursor: move; } +/* =Specific widget styling +-------------------------------------------------------------- */ +.text-widget-fields { + position: relative; +} +.text-widget-fields [hidden] { + display: none; +} +.text-widget-fields .wp-pointer.wp-pointer-top { + position: absolute; + z-index: 3; + top: 100px; + right: 10px; + left: 10px; +} +.text-widget-fields .wp-pointer .wp-pointer-arrow { + left: auto; + right: 15px; +} +.text-widget-fields .wp-pointer .wp-pointer-buttons { + line-height: 1.4em; +} + /* =Media Queries -------------------------------------------------------------- */ diff --git a/src/wp-admin/js/widgets/text-widgets.js b/src/wp-admin/js/widgets/text-widgets.js index 7932606e3d..c8022df501 100644 --- a/src/wp-admin/js/widgets/text-widgets.js +++ b/src/wp-admin/js/widgets/text-widgets.js @@ -3,7 +3,9 @@ wp.textWidgets = ( function( $ ) { 'use strict'; - var component = {}; + var component = { + dismissedPointers: [] + }; /** * Text widget control. @@ -45,6 +47,31 @@ wp.textWidgets = ( function( $ ) { control.$el.addClass( 'text-widget-fields' ); control.$el.html( wp.template( 'widget-text-control-fields' ) ); + control.customHtmlWidgetPointer = control.$el.find( '.wp-pointer.custom-html-widget-pointer' ); + if ( control.customHtmlWidgetPointer.length ) { + control.customHtmlWidgetPointer.find( '.close' ).on( 'click', function( event ) { + event.preventDefault(); + control.customHtmlWidgetPointer.hide(); + $( '#' + control.fields.text.attr( 'id' ) + '-html' ).focus(); + control.dismissPointers( [ 'text_widget_custom_html' ] ); + }); + control.customHtmlWidgetPointer.find( '.add-widget' ).on( 'click', function( event ) { + event.preventDefault(); + control.customHtmlWidgetPointer.hide(); + control.openAvailableWidgetsPanel(); + }); + } + + control.pasteHtmlPointer = control.$el.find( '.wp-pointer.paste-html-pointer' ); + if ( control.pasteHtmlPointer.length ) { + control.pasteHtmlPointer.find( '.close' ).on( 'click', function( event ) { + event.preventDefault(); + control.pasteHtmlPointer.hide(); + control.editor.focus(); + control.dismissPointers( [ 'text_widget_custom_html', 'text_widget_paste_html' ] ); + }); + } + control.fields = { title: control.$el.find( '.title' ), text: control.$el.find( '.text' ) @@ -65,6 +92,45 @@ wp.textWidgets = ( function( $ ) { }); }, + /** + * Dismiss pointers for Custom HTML widget. + * + * @since 4.8.1 + * + * @param {Array} pointers Pointer IDs to dismiss. + * @returns {void} + */ + dismissPointers: function dismissPointers( pointers ) { + _.each( pointers, function( pointer ) { + wp.ajax.post( 'dismiss-wp-pointer', { + pointer: pointer + }); + component.dismissedPointers.push( pointer ); + }); + }, + + /** + * Open available widgets panel. + * + * @since 4.8.1 + * @returns {void} + */ + openAvailableWidgetsPanel: function openAvailableWidgetsPanel() { + var sidebarControl; + wp.customize.section.each( function( section ) { + if ( section.extended( wp.customize.Widgets.SidebarSection ) && section.expanded() ) { + sidebarControl = wp.customize.control( 'sidebars_widgets[' + section.params.sidebarId + ']' ); + } + }); + if ( ! sidebarControl ) { + return; + } + setTimeout( function() { // Timeout to prevent click event from causing panel to immediately collapse. + wp.customize.Widgets.availableWidgetsPanel.open( sidebarControl ); + wp.customize.Widgets.availableWidgetsPanel.$search.val( 'HTML' ).trigger( 'keyup' ); + }); + }, + /** * Update input fields from the sync fields. * @@ -108,7 +174,7 @@ wp.textWidgets = ( function( $ ) { * @returns {void} */ function buildEditor() { - var editor, triggerChangeIfDirty, onInit; + var editor, triggerChangeIfDirty, onInit, showPointerElement; // Abort building if the textarea is gone, likely due to the widget having been deleted entirely. if ( ! document.getElementById( id ) ) { @@ -137,6 +203,20 @@ wp.textWidgets = ( function( $ ) { quicktags: true }); + /** + * Show a pointer, focus on dismiss, and speak the contents for a11y. + * + * @param {jQuery} pointerElement Pointer element. + * @returns {void} + */ + showPointerElement = function( pointerElement ) { + pointerElement.show(); + pointerElement.find( '.close' ).focus(); + wp.a11y.speak( pointerElement.find( 'h3, p' ).map( function() { + return $( this ).text(); + } ).get().join( '\n\n' ) ); + }; + editor = window.tinymce.get( id ); if ( ! editor ) { throw new Error( 'Failed to initialize editor' ); @@ -152,6 +232,34 @@ wp.textWidgets = ( function( $ ) { if ( restoreTextMode ) { switchEditors.go( id, 'toggle' ); } + + // Show the pointer. + $( '#' + id + '-html' ).on( 'click', function() { + control.pasteHtmlPointer.hide(); // Hide the HTML pasting pointer. + + if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_custom_html' ) ) { + return; + } + showPointerElement( control.customHtmlWidgetPointer ); + }); + + // Hide the pointer when switching tabs. + $( '#' + id + '-tmce' ).on( 'click', function() { + control.customHtmlWidgetPointer.hide(); + }); + + // Show pointer when pasting HTML. + editor.on( 'pastepreprocess', function( event ) { + var content = event.content; + if ( -1 !== component.dismissedPointers.indexOf( 'text_widget_paste_html' ) || ! content || ! /<\w+.*?>/.test( content ) ) { + return; + } + + // Show the pointer after a slight delay so the user sees what they pasted. + _.delay( function() { + showPointerElement( control.pasteHtmlPointer ); + }, 250 ); + }); }; if ( editor.initialized ) { @@ -233,6 +341,11 @@ wp.textWidgets = ( function( $ ) { return; } + // Bypass using TinyMCE when widget is in legacy mode. + if ( widgetForm.find( '.legacy' ).length > 0 ) { + return; + } + /* * Create a container element for the widget control fields. * This is inserted into the DOM immediately before the the .widget-content @@ -289,6 +402,11 @@ wp.textWidgets = ( function( $ ) { return; } + // Bypass using TinyMCE when widget is in legacy mode. + if ( widgetForm.find( '.legacy' ).length > 0 ) { + return; + } + fieldContainer = $( '
' ); syncContainer = widgetForm.find( '> .widget-inside' ); syncContainer.before( fieldContainer ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 7562e2839b..92ab72dfbb 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -608,7 +608,7 @@ function wp_default_scripts( &$scripts ) { $scripts->add( 'media-audio-widget', "/wp-admin/js/widgets/media-audio-widget$suffix.js", array( 'media-widgets', 'media-audiovideo' ) ); $scripts->add( 'media-image-widget', "/wp-admin/js/widgets/media-image-widget$suffix.js", array( 'media-widgets' ) ); $scripts->add( 'media-video-widget', "/wp-admin/js/widgets/media-video-widget$suffix.js", array( 'media-widgets', 'media-audiovideo' ) ); - $scripts->add( 'text-widgets', "/wp-admin/js/widgets/text-widgets$suffix.js", array( 'jquery', 'backbone', 'editor', 'wp-util' ) ); + $scripts->add( 'text-widgets', "/wp-admin/js/widgets/text-widgets$suffix.js", array( 'jquery', 'backbone', 'editor', 'wp-util', 'wp-a11y' ) ); $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 ); @@ -845,7 +845,7 @@ function wp_default_styles( &$styles ) { $styles->add( 'themes', "/wp-admin/css/themes$suffix.css" ); $styles->add( 'about', "/wp-admin/css/about$suffix.css" ); $styles->add( 'nav-menus', "/wp-admin/css/nav-menus$suffix.css" ); - $styles->add( 'widgets', "/wp-admin/css/widgets$suffix.css" ); + $styles->add( 'widgets', "/wp-admin/css/widgets$suffix.css", array( 'wp-pointer' ) ); $styles->add( 'site-icon', "/wp-admin/css/site-icon$suffix.css" ); $styles->add( 'l10n', "/wp-admin/css/l10n$suffix.css" ); diff --git a/src/wp-includes/widgets/class-wp-widget-text.php b/src/wp-includes/widgets/class-wp-widget-text.php index faa79674f6..ad4667bb0f 100644 --- a/src/wp-includes/widgets/class-wp-widget-text.php +++ b/src/wp-includes/widgets/class-wp-widget-text.php @@ -63,6 +63,129 @@ class WP_Widget_Text extends WP_Widget { add_action( 'admin_footer-widgets.php', array( $this, 'render_control_template_scripts' ) ); } + /** + * Determines whether a given instance is legacy and should bypass using TinyMCE. + * + * @since 4.8.1 + * + * @param array $instance { + * Instance data. + * + * @type string $text Content. + * @type bool|string $filter Whether autop or content filters should apply. + * @type bool $legacy Whether widget is in legacy mode. + * } + * @return bool Whether Text widget instance contains legacy data. + */ + public function is_legacy_instance( $instance ) { + + // If the widget has been updated while in legacy mode, it stays in legacy mode. + if ( ! empty( $instance['legacy'] ) ) { + return true; + } + + // If the widget has been added/updated in 4.8 then filter prop is 'content' and it is no longer legacy. + if ( isset( $instance['filter'] ) && 'content' === $instance['filter'] ) { + return false; + } + + // If the text is empty, then nothing is preventing migration to TinyMCE. + if ( empty( $instance['text'] ) ) { + return false; + } + + $wpautop = ! empty( $instance['filter'] ); + $has_line_breaks = ( false !== strpos( $instance['text'], "\n" ) ); + + // If auto-paragraphs are not enabled and there are line breaks, then ensure legacy mode. + if ( ! $wpautop && $has_line_breaks ) { + return true; + } + + // If an HTML comment is present, assume legacy mode. + if ( false !== strpos( $instance['text'], '', + 'filter' => true, + ) ); + $this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when HTML comment is present.' ); + + $instance = array_merge( $base_instance, array( + 'text' => 'Here is a [gallery]', + 'filter' => true, + ) ); + $this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy mode when a shortcode is present.' ); + + // Check text examples that will not migrate to TinyMCE. + $legacy_text_examples = array( + '', + '', + "", + '', + "", + "", + '', + "

\nStay updated with our latest news and specials. We never sell your information and you can unsubscribe at any time.\n

\n\n
\n\t
\n\n\t\t\n\t\t\n\n\t\t\n\n\t
\n
", + '', + ); + foreach ( $legacy_text_examples as $legacy_text_example ) { + $instance = array_merge( $base_instance, array( + 'text' => $legacy_text_example, + 'filter' => true, + ) ); + $this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when wpautop and there is HTML that is not liable to be mutated.' ); + + $instance = array_merge( $base_instance, array( + 'text' => $legacy_text_example, + 'filter' => false, + ) ); + $this->assertTrue( $widget->is_legacy_instance( $instance ), 'Legacy when not-wpautop and there is HTML that is not liable to be mutated.' ); + } + + // Check text examples that will migrate to TinyMCE, where elements and attributes are not in whitelist. + $migratable_text_examples = array( + 'Check out Example', + 'Img', + 'Hello', + 'Hello', + "", + "
    \n
  1. One
  2. \n
  3. One
  4. \n
  5. One
  6. \n
", + "Text\n
\nAddendum", + "Look at this code:\n\necho 'Hello World!';", + ); + foreach ( $migratable_text_examples as $migratable_text_example ) { + $instance = array_merge( $base_instance, array( + 'text' => $migratable_text_example, + 'filter' => true, + ) ); + $this->assertFalse( $widget->is_legacy_instance( $instance ), 'Legacy when wpautop and there is HTML that is not liable to be mutated.' ); + } + } + + /** + * Test update method. + * + * @covers WP_Widget_Text::form + */ + function test_form() { + $widget = new WP_Widget_Text(); + $instance = array( + 'title' => 'Title', + 'text' => 'Text', + 'filter' => false, + 'legacy' => true, + ); + $this->assertTrue( $widget->is_legacy_instance( $instance ) ); + ob_start(); + $widget->form( $instance ); + $form = ob_get_clean(); + $this->assertContains( 'class="legacy"', $form ); + + $instance = array( + 'title' => 'Title', + 'text' => 'Text', + 'filter' => 'content', + ); + $this->assertFalse( $widget->is_legacy_instance( $instance ) ); + ob_start(); + $widget->form( $instance ); + $form = ob_get_clean(); + $this->assertNotContains( 'class="legacy"', $form ); + } + /** * Test update method. * @@ -161,21 +388,21 @@ class Test_WP_Widget_Text extends WP_UnitTestCase { $instance = array( 'title' => "The\nTitle", 'text' => "The\n\nText", - 'filter' => false, + 'filter' => 'content', ); wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator', ) ) ); - // Should return valid instance. + // Should return valid instance in legacy mode since filter=false and there are line breaks. $expected = array( 'title' => sanitize_text_field( $instance['title'] ), 'text' => $instance['text'], 'filter' => 'content', ); $result = $widget->update( $instance, array() ); - $this->assertEquals( $result, $expected ); + $this->assertEquals( $expected, $result ); $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. @@ -184,7 +411,7 @@ class Test_WP_Widget_Text extends WP_UnitTestCase { $instance['text'] = ''; $expected['text'] = $instance['text']; $result = $widget->update( $instance, array() ); - $this->assertEquals( $result, $expected ); + $this->assertEquals( $expected, $result ); remove_filter( 'map_meta_cap', array( $this, 'grant_unfiltered_html_cap' ) ); add_filter( 'map_meta_cap', array( $this, 'revoke_unfiltered_html_cap' ), 10, 2 ); @@ -192,10 +419,62 @@ class Test_WP_Widget_Text extends WP_UnitTestCase { $instance['text'] = ''; $expected['text'] = wp_kses_post( $instance['text'] ); $result = $widget->update( $instance, array() ); - $this->assertEquals( $result, $expected ); + $this->assertEquals( $expected, $result ); remove_filter( 'map_meta_cap', array( $this, 'revoke_unfiltered_html_cap' ), 10 ); } + /** + * Test update for legacy widgets. + * + * @covers WP_Widget_Text::update + */ + function test_update_legacy() { + $widget = new WP_Widget_Text(); + + // Updating a widget with explicit filter=true persists with legacy mode. + $instance = array( + 'title' => 'Legacy', + 'text' => 'Text', + 'filter' => true, + ); + $result = $widget->update( $instance, array() ); + $expected = array_merge( $instance, array( + 'legacy' => true, + 'filter' => true, + ) ); + $this->assertEquals( $expected, $result ); + + // Updating a widget with explicit filter=false persists with legacy mode. + $instance['filter'] = false; + $result = $widget->update( $instance, array() ); + $expected = array_merge( $instance, array( + 'legacy' => true, + 'filter' => false, + ) ); + $this->assertEquals( $expected, $result ); + + // Updating a widget in legacy form results in filter=false when checkbox not checked. + $instance['filter'] = true; + $result = $widget->update( $instance, array() ); + $expected = array_merge( $instance, array( + 'legacy' => true, + 'filter' => true, + ) ); + $this->assertEquals( $expected, $result ); + + // Updating a widget that previously had legacy form results in filter persisting. + unset( $instance['legacy'] ); + $instance['filter'] = true; + $result = $widget->update( $instance, array( + 'legacy' => true, + ) ); + $expected = array_merge( $instance, array( + 'legacy' => true, + 'filter' => true, + ) ); + $this->assertEquals( $expected, $result ); + } + /** * Grant unfiltered_html cap via map_meta_cap. *