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. *