diff --git src/wp-admin/js/customize-controls.js src/wp-admin/js/customize-controls.js index 0a61a86..d7c2e75 100644 --- src/wp-admin/js/customize-controls.js +++ src/wp-admin/js/customize-controls.js @@ -3786,6 +3786,21 @@ }); }); + // Focus on the control that is associated with the given setting. + api.previewer.bind( 'focus-control-for-setting', function( settingId ) { + var matchedControl; + api.control.each( function( control ) { + var settingIds = _.pluck( control.settings, 'id' ); + if ( -1 !== _.indexOf( settingIds, settingId ) ) { + matchedControl = control; + } + } ); + + if ( matchedControl ) { + matchedControl.focus(); + } + } ); + api.trigger( 'ready' ); // Make sure left column gets focus diff --git src/wp-admin/js/customize-nav-menus.js src/wp-admin/js/customize-nav-menus.js index 6dea2b4..8e345e9 100644 --- src/wp-admin/js/customize-nav-menus.js +++ src/wp-admin/js/customize-nav-menus.js @@ -19,7 +19,7 @@ api.Menus.data = { itemTypes: [], l10n: {}, - menuItemTransport: 'postMessage', + settingTransport: 'refresh', phpIntMax: 0, defaultSettingValues: { nav_menu: {}, @@ -2307,7 +2307,7 @@ customizeId = 'nav_menu_item[' + String( placeholderId ) + ']'; settingArgs = { type: 'nav_menu_item', - transport: 'postMessage', + transport: api.Menus.data.settingTransport, previewer: api.previewer }; setting = api.create( customizeId, customizeId, {}, settingArgs ); @@ -2396,7 +2396,7 @@ // Register the menu control setting. api.create( customizeId, customizeId, {}, { type: 'nav_menu', - transport: 'postMessage', + transport: api.Menus.data.settingTransport, previewer: api.previewer } ); api( customizeId ).set( $.extend( @@ -2532,7 +2532,7 @@ newCustomizeId = 'nav_menu[' + String( update.term_id ) + ']'; newSetting = api.create( newCustomizeId, newCustomizeId, settingValue, { type: 'nav_menu', - transport: 'postMessage', + transport: api.Menus.data.settingTransport, previewer: api.previewer } ); @@ -2680,7 +2680,7 @@ newCustomizeId = 'nav_menu_item[' + String( update.post_id ) + ']'; newSetting = api.create( newCustomizeId, newCustomizeId, settingValue, { type: 'nav_menu_item', - transport: 'postMessage', + transport: api.Menus.data.settingTransport, previewer: api.previewer } ); diff --git src/wp-admin/js/customize-widgets.js src/wp-admin/js/customize-widgets.js index 360c183..91a6516 100644 --- src/wp-admin/js/customize-widgets.js +++ src/wp-admin/js/customize-widgets.js @@ -34,7 +34,7 @@ multi_number: null, name: null, id_base: null, - transport: 'refresh', + transport: api.Widgets.data.selectiveRefresh ? 'postMessage' : 'refresh', params: [], width: null, height: null, @@ -1982,7 +1982,7 @@ isExistingWidget = api.has( settingId ); if ( ! isExistingWidget ) { settingArgs = { - transport: 'refresh', + transport: api.Widgets.data.selectiveRefresh ? 'postMessage' : 'refresh', previewer: this.setting.previewer }; setting = api.create( settingId, settingId, '', settingArgs ); diff --git src/wp-content/themes/twentythirteen/js/theme-customizer.js src/wp-content/themes/twentythirteen/js/theme-customizer.js index 6072104..8519752 100644 --- src/wp-content/themes/twentythirteen/js/theme-customizer.js +++ src/wp-content/themes/twentythirteen/js/theme-customizer.js @@ -38,4 +38,16 @@ } } ); } ); + + if ( wp.customize.selectiveRefresh ) { + wp.customize.selectiveRefresh.bind( 'sidebar-updated', function( sidebarPartial ) { + var widgetArea; + if ( 'sidebar-1' === sidebarPartial.sidebarId && $.isFunction( $.fn.masonry ) ) { + widgetArea = $( '#secondary .widget-area' ); + widgetArea.masonry( 'destroy' ); + widgetArea.masonry(); + } + } ); + } + } )( jQuery ); diff --git src/wp-includes/class-wp-customize-manager.php src/wp-includes/class-wp-customize-manager.php index 942d907..a58cb94 100644 --- src/wp-includes/class-wp-customize-manager.php +++ src/wp-includes/class-wp-customize-manager.php @@ -67,6 +67,15 @@ final class WP_Customize_Manager { public $nav_menus; /** + * Methods and properties dealing with selective refresh in the Customizer preview. + * + * @since 4.5.0 + * @access public + * @var WP_Customize_Selective_Refresh + */ + public $selective_refresh; + + /** * Registered instances of WP_Customize_Setting. * * @since 3.4.0 @@ -100,7 +109,7 @@ final class WP_Customize_Manager { * @access protected * @var array */ - protected $components = array( 'widgets', 'nav_menus' ); + protected $components = array( 'widgets', 'nav_menus', 'selective_refresh' ); /** * Registered instances of WP_Customize_Section. @@ -249,14 +258,18 @@ final class WP_Customize_Manager { */ $components = apply_filters( 'customize_loaded_components', $this->components, $this ); - if ( in_array( 'widgets', $components ) ) { + if ( in_array( 'widgets', $components, true ) ) { require_once( ABSPATH . WPINC . '/class-wp-customize-widgets.php' ); $this->widgets = new WP_Customize_Widgets( $this ); } - if ( in_array( 'nav_menus', $components ) ) { + if ( in_array( 'nav_menus', $components, true ) ) { require_once( ABSPATH . WPINC . '/class-wp-customize-nav-menus.php' ); $this->nav_menus = new WP_Customize_Nav_Menus( $this ); } + if ( in_array( 'selective_refresh', $components, true ) ) { + require_once( ABSPATH . WPINC . '/customize/class-wp-customize-selective-refresh.php' ); + $this->selective_refresh = new WP_Customize_Selective_Refresh( $this ); + } add_filter( 'wp_die_handler', array( $this, 'wp_die_handler' ) ); @@ -1711,6 +1724,7 @@ final class WP_Customize_Manager { 'autofocus' => array(), 'documentTitleTmpl' => $this->get_document_title_template(), 'previewableDevices' => $this->get_previewable_devices(), + 'selectiveRefreshEnabled' => isset( $this->selective_refresh ), ); // Prepare Customize Section objects to pass to JavaScript. diff --git src/wp-includes/class-wp-customize-nav-menus.php src/wp-includes/class-wp-customize-nav-menus.php index 5453c17..2b355bd 100644 --- src/wp-includes/class-wp-customize-nav-menus.php +++ src/wp-includes/class-wp-customize-nav-menus.php @@ -61,6 +61,8 @@ final class WP_Customize_Nav_Menus { add_action( 'customize_controls_print_footer_scripts', array( $this, 'print_templates' ) ); add_action( 'customize_controls_print_footer_scripts', array( $this, 'available_items_template' ) ); add_action( 'customize_preview_init', array( $this, 'customize_preview_init' ) ); + + add_filter( 'customize_dynamic_partial_args', array( $this, 'customize_dynamic_partial_args' ), 10, 2 ); } /** @@ -375,7 +377,7 @@ final class WP_Customize_Nav_Menus { 'reorderLabelOn' => esc_attr__( 'Reorder menu items' ), 'reorderLabelOff' => esc_attr__( 'Close reorder mode' ), ), - 'menuItemTransport' => 'postMessage', + 'settingTransport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', 'phpIntMax' => PHP_INT_MAX, 'defaultSettingValues' => array( 'nav_menu' => $temp_nav_menu_setting->default, @@ -425,11 +427,13 @@ final class WP_Customize_Nav_Menus { public function filter_dynamic_setting_args( $setting_args, $setting_id ) { if ( preg_match( WP_Customize_Nav_Menu_Setting::ID_PATTERN, $setting_id ) ) { $setting_args = array( - 'type' => WP_Customize_Nav_Menu_Setting::TYPE, + 'type' => WP_Customize_Nav_Menu_Setting::TYPE, + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', ); } elseif ( preg_match( WP_Customize_Nav_Menu_Item_Setting::ID_PATTERN, $setting_id ) ) { $setting_args = array( - 'type' => WP_Customize_Nav_Menu_Item_Setting::TYPE, + 'type' => WP_Customize_Nav_Menu_Item_Setting::TYPE, + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', ); } return $setting_args; @@ -514,7 +518,7 @@ final class WP_Customize_Nav_Menus { $setting = $this->manager->get_setting( $setting_id ); if ( $setting ) { - $setting->transport = 'postMessage'; + $setting->transport = isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh'; remove_filter( "customize_sanitize_{$setting_id}", 'absint' ); add_filter( "customize_sanitize_{$setting_id}", array( $this, 'intval_base10' ) ); } else { @@ -522,7 +526,7 @@ final class WP_Customize_Nav_Menus { 'sanitize_callback' => array( $this, 'intval_base10' ), 'theme_supports' => 'menus', 'type' => 'theme_mod', - 'transport' => 'postMessage', + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', 'default' => 0, ) ); } @@ -548,7 +552,9 @@ final class WP_Customize_Nav_Menus { ) ) ); $nav_menu_setting_id = 'nav_menu[' . $menu_id . ']'; - $this->manager->add_setting( new WP_Customize_Nav_Menu_Setting( $this->manager, $nav_menu_setting_id ) ); + $this->manager->add_setting( new WP_Customize_Nav_Menu_Setting( $this->manager, $nav_menu_setting_id, array( + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', + ) ) ); // Add the menu contents. $menu_items = (array) wp_get_nav_menu_items( $menu_id ); @@ -561,7 +567,8 @@ final class WP_Customize_Nav_Menus { $value = (array) $item; $value['nav_menu_term_id'] = $menu_id; $this->manager->add_setting( new WP_Customize_Nav_Menu_Item_Setting( $this->manager, $menu_item_setting_id, array( - 'value' => $value, + 'value' => $value, + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', ) ) ); // Create a control for each menu item. @@ -585,7 +592,7 @@ final class WP_Customize_Nav_Menus { $this->manager->add_setting( 'new_menu_name', array( 'type' => 'new_menu', 'default' => '', - 'transport' => 'postMessage', + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', ) ); $this->manager->add_control( 'new_menu_name', array( @@ -801,28 +808,37 @@ final class WP_Customize_Nav_Menus { 'nav_menu_instance', + 'render_callback' => array( $this, 'render_nav_menu_partial' ), + 'container_inclusive' => true, + ) + ); + } + + return $partial_args; + } /** * Add hooks for the Customizer preview. @@ -831,13 +847,9 @@ final class WP_Customize_Nav_Menus { * @access public */ public function customize_preview_init() { - add_action( 'template_redirect', array( $this, 'render_menu' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'customize_preview_enqueue_deps' ) ); - - if ( ! isset( $_REQUEST[ self::RENDER_QUERY_VAR ] ) ) { - add_filter( 'wp_nav_menu_args', array( $this, 'filter_wp_nav_menu_args' ), 1000 ); - add_filter( 'wp_nav_menu', array( $this, 'filter_wp_nav_menu' ), 10, 2 ); - } + add_filter( 'wp_nav_menu_args', array( $this, 'filter_wp_nav_menu_args' ), 1000 ); + add_filter( 'wp_nav_menu', array( $this, 'filter_wp_nav_menu' ), 10, 2 ); } /** @@ -845,16 +857,13 @@ final class WP_Customize_Nav_Menus { * * @since 4.3.0 * @access public - * * @see wp_nav_menu() + * @see WP_Customize_Widgets_Partial_Refresh::filter_dynamic_sidebar_params() * * @param array $args An array containing wp_nav_menu() arguments. * @return array Arguments. */ public function filter_wp_nav_menu_args( $args ) { - $this->preview_nav_menu_instance_number += 1; - $args['instance_number'] = $this->preview_nav_menu_instance_number; - $can_partial_refresh = ( ! empty( $args['echo'] ) && @@ -867,30 +876,34 @@ final class WP_Customize_Nav_Menus { || ( ! empty( $args['menu'] ) && ( is_numeric( $args['menu'] ) || is_object( $args['menu'] ) ) ) ) + && + ( + ! empty( $args['container'] ) + || + ( isset( $args['items_wrap'] ) && '<' === substr( $args['items_wrap'], 0, 1 ) ) + ) ); - $args['can_partial_refresh'] = $can_partial_refresh; - - $hashed_args = $args; - if ( ! $can_partial_refresh ) { - $hashed_args['fallback_cb'] = ''; - $hashed_args['walker'] = ''; + return $args; } + $exported_args = $args; + // Replace object menu arg with a term_id menu arg, as this exports better to JS and is easier to compare hashes. - if ( ! empty( $hashed_args['menu'] ) && is_object( $hashed_args['menu'] ) ) { - $hashed_args['menu'] = $hashed_args['menu']->term_id; + if ( ! empty( $exported_args['menu'] ) && is_object( $exported_args['menu'] ) ) { + $exported_args['menu'] = $exported_args['menu']->term_id; } - ksort( $hashed_args ); - $hashed_args['args_hash'] = $this->hash_nav_menu_args( $hashed_args ); + ksort( $exported_args ); + $exported_args['args_hmac'] = $this->hash_nav_menu_args( $exported_args ); + + $args['customize_preview_nav_menus_args'] = $exported_args; - $this->preview_nav_menu_instance_args[ $this->preview_nav_menu_instance_number ] = $hashed_args; return $args; } /** - * Prepare wp_nav_menu() calls for partial refresh. Wraps output in container for refreshing. + * Prepare wp_nav_menu() calls for partial refresh. Injects attributes into container element. * * @since 4.3.0 * @access public @@ -902,20 +915,19 @@ final class WP_Customize_Nav_Menus { * @return null */ public function filter_wp_nav_menu( $nav_menu_content, $args ) { - if ( ! empty( $args->can_partial_refresh ) && ! empty( $args->instance_number ) ) { - $nav_menu_content = preg_replace( - '/(?<=class=")/', - sprintf( 'partial-refreshable-nav-menu partial-refreshable-nav-menu-%1$d ', $args->instance_number ), - $nav_menu_content, - 1 // Only update the class on the first element found, the menu container. - ); + if ( ! empty( $args->customize_preview_nav_menus_args ) ) { + $attributes = sprintf( ' data-customize-partial-id="%s"', esc_attr( 'nav_menu_instance[' . $args->customize_preview_nav_menus_args['args_hmac'] . ']' ) ); + $attributes .= ' data-customize-partial-type="nav_menu_instance"'; + $attributes .= sprintf( ' data-customize-partial-placement-context="%s"', esc_attr( wp_json_encode( $args->customize_preview_nav_menus_args ) ) ); + $nav_menu_content = preg_replace( '#^(<\w+)#', '$1 ' . $attributes, $nav_menu_content, 1 ); } return $nav_menu_content; } /** - * Hash (hmac) the arguments with the nonce and secret auth key to ensure they - * are not tampered with when submitted in the Ajax request. + * Hash (hmac) the nav menu args to ensure they are not tampered with when submitted in the Ajax request. + * + * Note that the array is expected to be pre-sorted. * * @since 4.3.0 * @access public @@ -924,7 +936,7 @@ final class WP_Customize_Nav_Menus { * @return string */ public function hash_nav_menu_args( $args ) { - return wp_hash( wp_create_nonce( self::RENDER_AJAX_ACTION ) . serialize( $args ) ); + return wp_hash( serialize( $args ) ); } /** @@ -934,32 +946,25 @@ final class WP_Customize_Nav_Menus { * @access public */ public function customize_preview_enqueue_deps() { - wp_enqueue_script( 'customize-preview-nav-menus' ); - wp_enqueue_style( 'customize-preview' ); + if ( isset( $this->manager->selective_refresh ) ) { + $script = wp_scripts()->registered['customize-preview-nav-menus']; + $script->deps[] = 'customize-selective-refresh'; + } - add_action( 'wp_print_footer_scripts', array( $this, 'export_preview_data' ) ); + wp_enqueue_script( 'customize-preview-nav-menus' ); // Note that we have overridden this. + wp_enqueue_style( 'customize-preview' ); } /** * Export data from PHP to JS. * + * @deprecated * @since 4.3.0 + * @since 4.5.0 Obsolete. * @access public */ public function export_preview_data() { - - // Why not wp_localize_script? Because we're not localizing, and it forces values into strings. - $exports = array( - 'renderQueryVar' => self::RENDER_QUERY_VAR, - 'renderNonceValue' => wp_create_nonce( self::RENDER_AJAX_ACTION ), - 'renderNoncePostKey' => self::RENDER_NONCE_POST_KEY, - 'navMenuInstanceArgs' => $this->preview_nav_menu_instance_args, - 'l10n' => array( - 'editNavMenuItemTooltip' => __( 'Shift-click to edit this menu item.' ), - ), - ); - - printf( '', wp_json_encode( $exports ) ); + _deprecated_function( __METHOD__, '4.5.0' ); } /** @@ -969,49 +974,29 @@ final class WP_Customize_Nav_Menus { * @access public * * @see wp_nav_menu() + * + * @param WP_Customize_Partial $partial Partial. + * @param array $nav_menu_args Nav menu args supplied as container context. + * @return string|false */ - public function render_menu() { - if ( empty( $_POST[ self::RENDER_QUERY_VAR ] ) ) { - return; - } + public function render_nav_menu_partial( $partial, $nav_menu_args ) { + unset( $partial ); - $this->manager->remove_preview_signature(); - - if ( empty( $_POST[ self::RENDER_NONCE_POST_KEY ] ) ) { - wp_send_json_error( 'missing_nonce_param' ); - } - - if ( ! is_customize_preview() ) { - wp_send_json_error( 'expected_customize_preview' ); + if ( ! isset( $nav_menu_args['args_hmac'] ) ) { + return false; // Error: missing_args_hmac. } + $nav_menu_args_hmac = $nav_menu_args['args_hmac']; + unset( $nav_menu_args['args_hmac'] ); - if ( ! check_ajax_referer( self::RENDER_AJAX_ACTION, self::RENDER_NONCE_POST_KEY, false ) ) { - wp_send_json_error( 'nonce_check_fail' ); + ksort( $nav_menu_args ); + if ( ! hash_equals( $this->hash_nav_menu_args( $nav_menu_args ), $nav_menu_args_hmac ) ) { + return false; // Error: args_hmac_mismatch. } - if ( ! current_user_can( 'edit_theme_options' ) ) { - wp_send_json_error( 'unauthorized' ); - } - - if ( ! isset( $_POST['wp_nav_menu_args'] ) ) { - wp_send_json_error( 'missing_param' ); - } - - if ( ! isset( $_POST['wp_nav_menu_args_hash'] ) ) { - wp_send_json_error( 'missing_param' ); - } - - $wp_nav_menu_args = json_decode( wp_unslash( $_POST['wp_nav_menu_args'] ), true ); - if ( ! is_array( $wp_nav_menu_args ) ) { - wp_send_json_error( 'wp_nav_menu_args_not_array' ); - } - - $wp_nav_menu_args_hash = sanitize_text_field( wp_unslash( $_POST['wp_nav_menu_args_hash'] ) ); - if ( ! hash_equals( $this->hash_nav_menu_args( $wp_nav_menu_args ), $wp_nav_menu_args_hash ) ) { - wp_send_json_error( 'wp_nav_menu_args_hash_mismatch' ); - } + ob_start(); + wp_nav_menu( $nav_menu_args ); + $content = ob_get_clean(); - $wp_nav_menu_args['echo'] = false; - wp_send_json_success( wp_nav_menu( $wp_nav_menu_args ) ); + return $content; } } diff --git src/wp-includes/class-wp-customize-widgets.php src/wp-includes/class-wp-customize-widgets.php index 5a0e62b..cf465e6 100644 --- src/wp-includes/class-wp-customize-widgets.php +++ src/wp-includes/class-wp-customize-widgets.php @@ -100,6 +100,10 @@ final class WP_Customize_Widgets { add_action( 'dynamic_sidebar', array( $this, 'tally_rendered_widgets' ) ); add_filter( 'is_active_sidebar', array( $this, 'tally_sidebars_via_is_active_sidebar_calls' ), 10, 2 ); add_filter( 'dynamic_sidebar_has_widgets', array( $this, 'tally_sidebars_via_dynamic_sidebar_calls' ), 10, 2 ); + + add_filter( 'customize_dynamic_partial_args', array( $this, 'customize_dynamic_partial_args' ), 10, 2 ); + add_filter( 'widget_customizer_setting_args', array( $this, 'filter_widget_customizer_setting_args' ), 10, 2 ); + add_action( 'customize_preview_init', array( $this, 'selective_refresh_init' ) ); } /** @@ -682,6 +686,7 @@ final class WP_Customize_Widgets { 'widgetReorderNav' => $widget_reorder_nav_tpl, 'moveWidgetArea' => $move_widget_area_tpl, ), + 'selectiveRefresh' => isset( $this->manager->selective_refresh ), ); foreach ( $settings['registeredWidgets'] as &$registered_widget ) { @@ -762,7 +767,7 @@ final class WP_Customize_Widgets { $args = array( 'type' => 'option', 'capability' => 'edit_theme_options', - 'transport' => 'refresh', + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', 'default' => array(), ); @@ -884,7 +889,7 @@ final class WP_Customize_Widgets { 'multi_number' => ( $args['_add'] === 'multi' ) ? $args['_multi_num'] : false, 'is_disabled' => $is_disabled, 'id_base' => $id_base, - 'transport' => 'refresh', + 'transport' => isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh', 'width' => $wp_registered_widget_controls[$widget['id']]['width'], 'height' => $wp_registered_widget_controls[$widget['id']]['height'], 'is_wide' => $this->is_wide_widget( $widget['id'] ), @@ -1061,8 +1066,9 @@ final class WP_Customize_Widgets { 'registeredSidebars' => array_values( $wp_registered_sidebars ), 'registeredWidgets' => $wp_registered_widgets, 'l10n' => array( - 'widgetTooltip' => __( 'Shift-click to edit this widget.' ), + 'widgetTooltip' => __( 'Shift-click to edit this widget.' ), ), + 'selectiveRefresh' => isset( $this->manager->selective_refresh ), ); foreach ( $settings['registeredWidgets'] as &$registered_widget ) { unset( $registered_widget['callback'] ); // may not be JSON-serializeable @@ -1459,9 +1465,333 @@ final class WP_Customize_Widgets { wp_send_json_success( compact( 'form', 'instance' ) ); } - /*************************************************************************** + /* + * Selective Refresh Methods + */ + + /** + * Let sidebars_widgets and widget instance settings all have postMessage transport. + * + * The preview will determine whether or not the setting change requires a full refresh. + * + * @param array $args Setting args. + * @return array + */ + public function filter_widget_customizer_setting_args( $args ) { + $args['transport'] = isset( $this->manager->selective_refresh ) ? 'postMessage' : 'refresh'; + return $args; + } + + /** + * Filter args for dynamic widget partials. + * + * @since 4.5.0 + * + * @param array|false $partial_args Partial args. + * @param string $partial_id Partial ID. + * @return array Partial args + */ + public function customize_dynamic_partial_args( $partial_args, $partial_id ) { + + if ( preg_match( '/^widget\[.+\]$/', $partial_id ) ) { + if ( false === $partial_args ) { + $partial_args = array(); + } + $partial_args = array_merge( + $partial_args, + array( + 'type' => 'widget', + 'render_callback' => array( $this, 'render_widget_partial' ), + ) + ); + } + + return $partial_args; + } + + /** + * Add hooks for selective refresh. + * + * @since 4.5.0 + * @access public + */ + public function selective_refresh_init() { + if ( ! isset( $this->manager->selective_refresh ) ) { + return; + } + + add_action( 'wp_enqueue_scripts', array( $this, 'customize_preview_enqueue_deps' ) ); + add_filter( 'dynamic_sidebar_params', array( $this, 'filter_dynamic_sidebar_params' ) ); + add_filter( 'wp_kses_allowed_html', array( $this, 'filter_wp_kses_allowed_data_attributes' ) ); + add_action( 'dynamic_sidebar_before', array( $this, 'start_dynamic_sidebar' ) ); + add_action( 'dynamic_sidebar_after', array( $this, 'end_dynamic_sidebar' ) ); + } + + /** + * Enqueue scripts for the Customizer preview. + * + * @since 4.5.0 + * @access public + */ + public function customize_preview_enqueue_deps() { + if ( isset( $this->manager->selective_refresh ) ) { + $script = wp_scripts()->registered['customize-preview-widgets']; + $script->deps[] = 'customize-selective-refresh'; + } + + wp_enqueue_script( 'customize-preview-widgets' ); + wp_enqueue_style( 'customize-preview' ); + wp_enqueue_style( 'customize-partial-refresh-widgets-preview' ); + } + + /** + * Keep track of the arguments that are being passed to the_widget(). + * + * @param array $params { + * Dynamic sidebar params. + * + * @type array $args Sidebar args. + * @type array $widget_args Widget args. + * } + * @see WP_Customize_Nav_Menus_Partial_Refresh::filter_wp_nav_menu_args() + * + * @return array Params. + */ + public function filter_dynamic_sidebar_params( $params ) { + $sidebar_args = array_merge( + array( + 'before_widget' => '', + 'after_widget' => '', + ), + $params[0] + ); + + // Skip widgets not in a registered sidebar or ones which lack a proper wrapper element to attach the data-* attributes to. + $matches = array(); + $is_valid = ( + isset( $sidebar_args['id'] ) + && + is_registered_sidebar( $sidebar_args['id'] ) + && + ( isset( $this->current_dynamic_sidebar_id_stack[0] ) && $this->current_dynamic_sidebar_id_stack[0] === $sidebar_args['id'] ) + && + preg_match( '#^<(?P\w+)#', $sidebar_args['before_widget'], $matches ) + ); + if ( ! $is_valid ) { + return $params; + } + $this->before_widget_tags_seen[ $matches['tag_name'] ] = true; + + $context = array( + 'sidebar_id' => $sidebar_args['id'], + ); + if ( isset( $this->context_sidebar_instance_number ) ) { + $context['sidebar_instance_number'] = $this->context_sidebar_instance_number; + } else if ( isset( $sidebar_args['id'] ) && isset( $this->sidebar_instance_count[ $sidebar_args['id'] ] ) ) { + $context['sidebar_instance_number'] = $this->sidebar_instance_count[ $sidebar_args['id'] ]; + } + + $attributes = sprintf( ' data-customize-partial-id="%s"', esc_attr( 'widget[' . $sidebar_args['widget_id'] . ']' ) ); + $attributes .= ' data-customize-partial-type="widget"'; + $attributes .= sprintf( ' data-customize-partial-placement-context="%s"', esc_attr( wp_json_encode( $context ) ) ); + $attributes .= sprintf( ' data-customize-widget-id="%s"', esc_attr( $sidebar_args['widget_id'] ) ); + $sidebar_args['before_widget'] = preg_replace( '#^(<\w+)#', '$1 ' . $attributes, $sidebar_args['before_widget'] ); + + $params[0] = $sidebar_args; + return $params; + } + + /** + * List of the tag names seen for before_widget strings. + * + * This is used in the filter_wp_kses_allowed_html filter to ensure that the + * data-* attributes can be whitelisted. + * + * @since 4.5.0 + * @access private + * @var array + */ + protected $before_widget_tags_seen = array(); + + /** + * Ensure that the HTML data-* attributes for selective refresh are allowed by kses. + * + * This is needed in case the $before_widget is run through wp_kses() when printed. + * + * @since 4.5.0 + * @access private + * + * @param array $allowed_html Allowed HTML. + * @return array Allowed HTML. + */ + function filter_wp_kses_allowed_data_attributes( $allowed_html ) { + foreach ( array_keys( $this->before_widget_tags_seen ) as $tag_name ) { + if ( ! isset( $allowed_html[ $tag_name ] ) ) { + $allowed_html[ $tag_name ] = array(); + } + $allowed_html[ $tag_name ] = array_merge( + $allowed_html[ $tag_name ], + array_fill_keys( array( + 'data-customize-partial-id', + 'data-customize-partial-type', + 'data-customize-partial-placement-context', + 'data-customize-partial-widget-id', + 'data-customize-partial-options', + ), true ) + ); + } + return $allowed_html; + } + + /** + * Keep track of the number of times that dynamic_sidebar() was called for a given sidebar index. + * + * This helps facilitate the uncommon scenario where a single sidebar is rendered multiple times on a template. + * + * @since 4.5.0 + * @access private + * @var array + */ + protected $sidebar_instance_count = array(); + + /** + * The current request's sidebar_instance_number context. + * + * @since 4.5.0 + * @access private + * @var int + */ + protected $context_sidebar_instance_number; + + /** + * Current sidebar ID being rendered. + * + * @since 4.5.0 + * @access private + * @var array + */ + protected $current_dynamic_sidebar_id_stack = array(); + + /** + * Start keeping track of the current sidebar being rendered. + * + * Insert marker before widgets are rendered in a dynamic sidebar. + * + * @since 4.5.0 + * + * @param int|string $index Index, name, or ID of the dynamic sidebar. + */ + public function start_dynamic_sidebar( $index ) { + array_unshift( $this->current_dynamic_sidebar_id_stack, $index ); + if ( ! isset( $this->sidebar_instance_count[ $index ] ) ) { + $this->sidebar_instance_count[ $index ] = 0; + } + $this->sidebar_instance_count[ $index ] += 1; + if ( ! $this->manager->selective_refresh->is_render_partials_request() ) { + printf( "\n\n", esc_html( $index ), intval( $this->sidebar_instance_count[ $index ] ) ); + } + } + + /** + * Finish keeping track of the current sidebar being rendered. + * + * Insert marker after widgets are rendered in a dynamic sidebar. + * + * @since 4.5.0 + * + * @param int|string $index Index, name, or ID of the dynamic sidebar. + */ + public function end_dynamic_sidebar( $index ) { + assert( array_shift( $this->current_dynamic_sidebar_id_stack ) === $index ); + if ( ! $this->manager->selective_refresh->is_render_partials_request() ) { + printf( "\n\n", esc_html( $index ), intval( $this->sidebar_instance_count[ $index ] ) ); + } + } + + /** + * Current sidebar being rendered. + * + * @since 4.5.0 + * @access private + * @var string + */ + protected $rendering_widget_id; + + /** + * Current widget being rendered. + * + * @since 4.5.0 + * @access private + * @var string + */ + protected $rendering_sidebar_id; + + /** + * Filter sidebars_widgets to ensure the currently-rendered widget is the only widget in the current sidebar. + * + * @since 4.5.0 + * @access private + * + * @param array $sidebars_widgets Sidebars widgets. + * @return array Sidebars widgets. + */ + public function filter_sidebars_widgets_for_rendering_widget( $sidebars_widgets ) { + $sidebars_widgets[ $this->rendering_sidebar_id ] = array( $this->rendering_widget_id ); + return $sidebars_widgets; + } + + /** + * Render a specific widget using the supplied sidebar arguments. + * + * @since 4.5.0 + * @access public + * + * @see dynamic_sidebar() + * + * @param WP_Customize_Partial $partial Partial. + * @param array $context { + * Sidebar args supplied as container context. + * + * @type string $sidebar_id ID for sidebar for widget to render into. + * @type int [$sidebar_instance_number] Disambiguating instance number. + * } + * @return string|false + */ + public function render_widget_partial( $partial, $context ) { + $id_data = $partial->id_data(); + $widget_id = array_shift( $id_data['keys'] ); + if ( ! is_array( $context ) || empty( $context['sidebar_id'] ) || ! is_registered_sidebar( $context['sidebar_id'] ) ) { + return false; + } + $this->rendering_sidebar_id = $context['sidebar_id']; + + if ( isset( $context['sidebar_instance_number'] ) ) { + $this->context_sidebar_instance_number = intval( $context['sidebar_instance_number'] ); + } + + // Filter sidebars_widgets so that only the queried widget is in the sidebar. + $this->rendering_widget_id = $widget_id; + + $filter_callback = array( $this, 'filter_sidebars_widgets_for_rendering_widget' ); + add_filter( 'sidebars_widgets', $filter_callback, 1000 ); + + // Render the widget. + ob_start(); + dynamic_sidebar( $this->rendering_sidebar_id = $context['sidebar_id'] ); + $container = ob_get_clean(); + + // Reset variables for next partial render. + remove_filter( 'sidebars_widgets', $filter_callback, 1000 ); + $this->context_sidebar_instance_number = null; + $this->rendering_sidebar_id = null; + $this->rendering_widget_id = null; + + return $container; + } + + /* * Option Update Capturing - ***************************************************************************/ + */ /** * List of captured widget option updates. @@ -1611,7 +1941,7 @@ final class WP_Customize_Widgets { return; } - remove_filter( 'pre_update_option', array( $this, 'capture_filter_pre_update_option' ), 10, 3 ); + remove_filter( 'pre_update_option', array( $this, 'capture_filter_pre_update_option' ), 10 ); foreach ( array_keys( $this->_captured_options ) as $option_name ) { remove_filter( "pre_option_{$option_name}", array( $this, 'capture_filter_pre_get_option' ) ); diff --git src/wp-includes/css/customize-preview.css src/wp-includes/css/customize-preview.css index bc4a6fe..75251ea 100644 --- src/wp-includes/css/customize-preview.css +++ src/wp-includes/css/customize-preview.css @@ -4,3 +4,9 @@ transition: opacity 0.25s; cursor: progress; } + +/* Override highlight when refreshing */ +.customize-partial-refreshing.widget-customizer-highlighted-widget { + -webkit-box-shadow: none; + box-shadow: none; +} diff --git src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php index 073423e..ddfd47b 100644 --- src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php +++ src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php @@ -70,7 +70,7 @@ class WP_Customize_Nav_Menu_Item_Setting extends WP_Customize_Setting { * @access public * @var string */ - public $transport = 'postMessage'; + public $transport = 'refresh'; /** * The post ID represented by this setting instance. This is the db_id. diff --git src/wp-includes/customize/class-wp-customize-partial.php src/wp-includes/customize/class-wp-customize-partial.php new file mode 100644 index 0000000..a481250 --- /dev/null +++ src/wp-includes/customize/class-wp-customize-partial.php @@ -0,0 +1,279 @@ +$key = $args[ $key ]; + } + } + + $this->component = $component; + $this->id = $id; + $this->id_data['keys'] = preg_split( '/\[/', str_replace( ']', '', $this->id ) ); + $this->id_data['base'] = array_shift( $this->id_data['keys'] ); + + if ( empty( $this->render_callback ) ) { + $this->render_callback = array( $this, 'render_callback' ); + } + + // Process settings. + if ( empty( $this->settings ) ) { + $this->settings = array( $id ); + } else if ( ! is_array( $this->settings ) ) { + $this->settings = array( $this->settings ); + } + if ( empty( $this->primary_setting ) ) { + $this->primary_setting = current( $this->settings ); + } + } + + /** + * Get parsed ID data for multidimensional setting. + * + * @since 4.5.0 + * @access public + * + * @return array { + * ID data for multidimensional partial. + * + * @type string $base ID base. + * @type array $keys Keys for multidimensional array. + * } + */ + final public function id_data() { + return $this->id_data; + } + + /** + * Render the template partial involving the associated settings. + * + * @since 4.5.0 + * @access public + * + * @param array $container_context Optional array of context data associated with the target container. + * @return string|array|false The rendered partial as a string, raw data array (for client-side JS template), or false if no render applied. + */ + final public function render( $container_context = array() ) { + $partial = $this; + + $rendered = false; + if ( ! empty( $this->render_callback ) ) { + ob_start(); + $return_render = call_user_func( $this->render_callback, $this, $container_context ); + $ob_render = ob_get_clean(); + + if ( null !== $return_render && '' !== $ob_render ) { + _doing_it_wrong( __FUNCTION__, __( 'Partial render must echo the content or return the content string (or array), but not both.' ), '4.5.0' ); + } + + // Note that the string return takes precedence because the $ob_render may just include PHP warnings or notices. + if ( null !== $return_render ) { + $rendered = $return_render; + } else { + $rendered = $ob_render; + } + } + + /** + * Filter partial rendering. + * + * @since 4.5.0 + * + * @param string|array|false $rendered The partial value. Default false. + * @param WP_Customize_Partial $partial WP_Customize_Setting instance. + * @param array $container_context Optional array of context data associated with the target container. + */ + $rendered = apply_filters( 'customize_partial_render', $rendered, $partial, $container_context ); + + /** + * Filter partial rendering by the partial ID. + * + * @since 4.5.0 + * + * @param string|array|false $rendered The partial value. Default false. + * @param WP_Customize_Partial $partial WP_Customize_Setting instance. + * @param array $container_context Optional array of context data associated with the target container. + */ + $rendered = apply_filters( "customize_partial_render_{$partial->id}", $rendered, $partial, $container_context ); + + return $rendered; + } + + /** + * Default callback used when invoking WP_Customize_Control::render(). + * + * Note that this method may echo the partial *or* return the partial as + * a string or array, but not both. Output buffering is performed when this + * is called. Subclasses can override this with their specific logic, or they + * may provide an 'render_callback' argument to the constructor. + * + * This method may return an HTML string for straight DOM injection, or it + * may return an array for supporting Partial JS subclasses to render by + * applying to client-side templating. + * + * @access public + * @since 4.5.0 + * + * @return string|array|false + */ + public function render_callback() { + return false; + } + + /** + * Get the data to export to the client via JSON. + * + * @since 4.5.0 + * + * @return array Array of parameters passed to the JavaScript. + */ + public function json() { + $exports = array(); + $exports['settings'] = $this->settings; + $exports['primarySetting'] = $this->primary_setting; + $exports['selector'] = $this->selector; + $exports['type'] = $this->type; + $exports['fallbackRefresh'] = $this->fallback_refresh; + $exports['containerInclusive'] = $this->container_inclusive; + return $exports; + } +} diff --git src/wp-includes/customize/class-wp-customize-selective-refresh.php src/wp-includes/customize/class-wp-customize-selective-refresh.php new file mode 100644 index 0000000..f7587eb --- /dev/null +++ src/wp-includes/customize/class-wp-customize-selective-refresh.php @@ -0,0 +1,425 @@ +manager = $manager; + require_once( ABSPATH . WPINC . '/customize/class-wp-customize-partial.php' ); + + add_action( 'customize_controls_print_footer_scripts', array( $this, 'enqueue_pane_scripts' ) ); + add_action( 'customize_preview_init', array( $this, 'init_preview' ) ); + } + + /** + * Registered instances of WP_Customize_Partial. + * + * @since 4.5.0 + * @access protected + * @var WP_Customize_Partial[] + */ + protected $partials = array(); + + /** + * Get the registered partials. + * + * @since 4.5.0 + * @access public + * + * @return WP_Customize_Partial[] Partials. + */ + public function partials() { + return $this->partials; + } + + /** + * Add a customize partial. + * + * @since 4.5.0 + * @access public + * + * @param WP_Customize_Partial|string $id Customize Partial object, or Panel ID. + * @param array $args Optional. Partial arguments. Default empty array. + * + * @return WP_Customize_Partial The instance of the panel that was added. + */ + public function add_partial( $id, $args = array() ) { + if ( $id instanceof WP_Customize_Partial ) { + $partial = $id; + } else { + $class = 'WP_Customize_Partial'; + + /** This filter (will be) documented in wp-includes/class-wp-customize-manager.php */ + $args = apply_filters( 'customize_dynamic_partial_args', $args, $id ); + + /** This filter (will be) documented in wp-includes/class-wp-customize-manager.php */ + $class = apply_filters( 'customize_dynamic_partial_class', $class, $id, $args ); + + $partial = new $class( $this, $id, $args ); + } + + $this->partials[ $partial->id ] = $partial; + return $partial; + } + + /** + * Retrieve a customize partial. + * + * @since 4.5.0 + * @access public + * + * @param string $id Customize Partial ID. + * @return WP_Customize_Partial|null The partial, if set. + */ + public function get_partial( $id ) { + if ( isset( $this->partials[ $id ] ) ) { + return $this->partials[ $id ]; + } else { + return null; + } + } + + /** + * Remove a customize partial. + * + * @since 4.5.0 + * @access public + * + * @param string $id Customize Partial ID. + */ + public function remove_partial( $id ) { + unset( $this->partials[ $id ] ); + } + + /** + * Initialize Customizer preview. + * + * @since 4.5.0 + * @access public + */ + public function init_preview() { + add_action( 'template_redirect', array( $this, 'handle_render_partials_request' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_preview_scripts' ) ); + } + + /** + * Enqueue pane scripts. + * + * @since 4.5.0 + * @access public + */ + public function enqueue_pane_scripts() { + wp_enqueue_script( 'customize-controls-hacks' ); + } + + /** + * Enqueue preview scripts. + * + * @since 4.5.0 + * @access public + */ + public function enqueue_preview_scripts() { + wp_enqueue_script( 'customize-selective-refresh' ); + add_action( 'wp_footer', array( $this, 'export_preview_data' ), 1000 ); + } + + /** + * Export data in preview after it has finished rendering so that partials can be added at runtime. + * + * @since 4.5.0 + * @access public + */ + public function export_preview_data() { + + $partials = array(); + foreach ( $this->partials() as $partial ) { + $partials[ $partial->id ] = $partial->json(); + } + + $exports = array( + 'partials' => $partials, + 'renderQueryVar' => self::RENDER_QUERY_VAR, + 'l10n' => array( + 'shiftClickToEdit' => __( 'Shift-click to edit this element.' ), + ), + ); + + // Export data to JS. + echo sprintf( '', wp_json_encode( $exports ) ); + } + + /** + * Register dynamically-created partials. + * + * @since 4.5.0 + * @access public + * @see WP_Customize_Manager::add_dynamic_settings() + * + * @param array $partial_ids The partial ID to add. + * @return array Added WP_Customize_Partial instances. + */ + public function add_dynamic_partials( $partial_ids ) { + $new_partials = array(); + + foreach ( $partial_ids as $partial_id ) { + + // Skip partials already created. + $partial = $this->get_partial( $partial_id ); + if ( $partial ) { + continue; + } + + $partial_args = false; + $partial_class = 'WP_Customize_Partial'; + + /** + * Filter a dynamic partial's constructor args. + * + * For a dynamic partial to be registered, this filter must be employed + * to override the default false value with an array of args to pass to + * the WP_Customize_Partial constructor. + * + * @since 4.5.0 + * + * @param false|array $partial_args The arguments to the WP_Customize_Partial constructor. + * @param string $partial_id ID for dynamic partial. + */ + $partial_args = apply_filters( 'customize_dynamic_partial_args', $partial_args, $partial_id ); + if ( false === $partial_args ) { + continue; + } + + /** + * Allow non-statically created partials to be constructed with custom WP_Customize_Partial subclass. + * + * @since 4.5.0 + * + * @param string $partial_class WP_Customize_Partial or a subclass. + * @param string $partial_id ID for dynamic partial. + * @param array $partial_args The arguments to the WP_Customize_Partial constructor. + */ + $partial_class = apply_filters( 'customize_dynamic_partial_class', $partial_class, $partial_id, $partial_args ); + + $partial = new $partial_class( $this, $partial_id, $partial_args ); + + $this->add_partial( $partial ); + $new_partials[] = $partial; + } + return $new_partials; + } + + /** + * Check whether the request is for rendering partials. + * + * Note that this will not consider whether the request is authorized or valid, + * just that essentially the route is a match. + * + * @since 4.5.0 + * @access public + * + * @return bool Whether the request is for rendering partials. + */ + public function is_render_partials_request() { + return ! empty( $_POST[ self::RENDER_QUERY_VAR ] ); + } + + /** + * Log of errors triggered when partials are rendered. + * + * @since 4.5.0 + * @access private + * @var array + */ + protected $triggered_errors = array(); + + /** + * Keep track of the current partial being rendered. + * + * @since 4.5.0 + * @access private + * @var string + */ + protected $current_partial_id; + + /** + * Handle PHP error triggered during rendering the partials. + * + * These errors will be relayed back to the client in the Ajax response. + * + * @since 4.5.0 + * @access private + * + * @param int $errno Error number. + * @param string $errstr Error string. + * @param string $errfile Error file. + * @param string $errline Error line. + * @return bool + */ + public function handle_error( $errno, $errstr, $errfile = null, $errline = null ) { + $this->triggered_errors[] = array( + 'partial' => $this->current_partial_id, + 'error_number' => $errno, + 'error_string' => $errstr, + 'error_file' => $errfile, + 'error_line' => $errline, + ); + return true; + } + + /** + * Handle Ajax request to return the rendered partials for the requested placements. + * + * @since 4.5.0 + * @access public + */ + public function handle_render_partials_request() { + if ( ! $this->is_render_partials_request() ) { + return; + } + + $this->manager->remove_preview_signature(); + + /* + * Note that is_customize_preview() returning true will entail that the + * user passed the 'customize' capability check and the nonce check, since + * WP_Customize_Manager::setup_theme() is where the previewing flag is set. + */ + if ( ! is_customize_preview() ) { + status_header( 403 ); + wp_send_json_error( 'expected_customize_preview' ); + } else if ( ! isset( $_POST['partials'] ) ) { + status_header( 400 ); + wp_send_json_error( 'missing_partials' ); + } + $partials = json_decode( wp_unslash( $_POST['partials'] ), true ); + if ( ! is_array( $partials ) ) { + wp_send_json_error( 'malformed_partials' ); + } + $this->add_dynamic_partials( array_keys( $partials ) ); + + /** + * Do setup before rendering each partial. + * + * Plugins may do things like call wp_enqueue_scripts() and + * gather a list of the scripts and styles which may get enqueued in the response. + * + * @since 4.5.0 + * + * @param WP_Customize_Selective_Refresh $this Selective refresh component. + * @param array $partials IDs for the partials to render in the request. + */ + do_action( 'customize_render_partials_before', $this, $partials ); + + set_error_handler( array( $this, 'handle_error' ), error_reporting() ); + $contents = array(); + foreach ( $partials as $partial_id => $container_contexts ) { + $this->current_partial_id = $partial_id; + if ( ! is_array( $container_contexts ) ) { + wp_send_json_error( 'malformed_container_contexts' ); + } + + $partial = $this->get_partial( $partial_id ); + if ( ! $partial ) { + $contents[ $partial_id ] = null; + continue; + } + + $contents[ $partial_id ] = array(); + + // @todo The array should include not only the contents, but also whether the container is included? + if ( empty( $container_contexts ) ) { + // Since there are no container contexts, render just once. + $contents[ $partial_id ][] = $partial->render( null ); + } else { + foreach ( $container_contexts as $container_context ) { + $contents[ $partial_id ][] = $partial->render( $container_context ); + } + } + } + $this->current_partial_id = null; + restore_error_handler(); + + /** + * Do finalization after rendering each partial. + * + * Plugins may do things like call wp_footer() to scrape scripts output and + * return them via the customize_render_partials_response filter. + * + * @since 4.5.0 + * + * @param WP_Customize_Selective_Refresh $this Selective refresh component. + * @param array $partials IDs for the partials to rendered in the request. + */ + do_action( 'customize_render_partials_after', $this, $partials ); + + $response = array( + 'contents' => $contents, + ); + if ( defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY ) { + $response['errors'] = $this->triggered_errors; + } + + /** + * Filter the response from rendering the partials. + * + * Plugins may use this filter to inject $scripts and + * $styles which are dependencies for the partials being + * rendered. The response data will be available to the client via the + * render-partials-response JS event, so the client can then + * inject the scripts and styles into the DOM if they have not already + * been enqueued there. If plugins do this, they'll need to take care + * for any scripts that do document.write() and make sure + * that these are not injected, or else to override the function to no-op, + * or else the page will be destroyed. + * + * Plugins should be aware that $scripts and $styles + * these may eventually be included by default in the response. + * + * @since 4.5.0 + * + * @param array $response { + * Response. + * + * @type array $contents Associative array mapping a partial ID its corresponding array of contents for the containers requested. + * @type array [$errors] List of errors triggered during rendering of partials, if WP_DEBUG_DISPLAY is enabled. + * } + * @param WP_Customize_Selective_Refresh $this Selective refresh component. + * @param array $partials IDs for the partials to rendered in the request. + */ + $response = apply_filters( 'customize_render_partials_response', $response, $this, $partials ); + + wp_send_json_success( $response ); + } +} diff --git src/wp-includes/js/customize-preview-nav-menus.js src/wp-includes/js/customize-preview-nav-menus.js index 9e84494..c61e620 100644 --- src/wp-includes/js/customize-preview-nav-menus.js +++ src/wp-includes/js/customize-preview-nav-menus.js @@ -1,315 +1,197 @@ -/* global JSON, _wpCustomizePreviewNavMenusExports */ - -( function( $, _, wp ) { +wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( $, _, wp, api ) { 'use strict'; - if ( ! wp || ! wp.customize ) { return; } - - var api = wp.customize, - currentRefreshDebounced = {}, - refreshDebounceDelay = 200, - settings = {}, - defaultSettings = { - renderQueryVar: null, - renderNonceValue: null, - renderNoncePostKey: null, - requestUri: '/', - navMenuInstanceArgs: {}, - l10n: {} - }; - - api.MenusCustomizerPreview = { - /** - * Bootstrap functionality. - */ - init : function() { - var self = this, initializedSettings = {}; - - settings = _.extend( {}, defaultSettings ); - if ( 'undefined' !== typeof _wpCustomizePreviewNavMenusExports ) { - _.extend( settings, _wpCustomizePreviewNavMenusExports ); - } - - api.each( function( setting, id ) { - setting.id = id; - initializedSettings[ setting.id ] = true; - self.bindListener( setting ); - } ); - - api.preview.bind( 'setting', function( args ) { - var id, value, setting; - args = args.slice(); - id = args.shift(); - value = args.shift(); + var self = {}; - setting = api( id ); - if ( ! setting ) { - // Currently customize-preview.js is not creating settings for dynamically-created settings in the pane, so we have to do it. - setting = api.create( id, value ); // @todo This should be in core - } - if ( ! setting.id ) { - // Currently customize-preview.js doesn't set the id property for each setting, like customize-controls.js does. - setting.id = id; - } + /** + * Initialize nav menus preview. + */ + self.init = function() { + var self = this; - if ( ! initializedSettings[ setting.id ] ) { - initializedSettings[ setting.id ] = true; - if ( self.bindListener( setting ) ) { - setting.callbacks.fireWith( setting, [ setting(), null ] ); - } - } - } ); + if ( api.selectiveRefresh ) { + self.watchNavMenuLocationChanges(); + } + api.preview.bind( 'active', function() { self.highlightControls(); - }, - - /** - * - * @param {wp.customize.Value} setting - * @returns {boolean} Whether the setting was bound. - */ - bindListener : function( setting ) { - var matches, themeLocation; - - matches = setting.id.match( /^nav_menu\[(-?\d+)]$/ ); - if ( matches ) { - setting.navMenuId = parseInt( matches[1], 10 ); - setting.bind( this.onChangeNavMenuSetting ); - return true; - } - - matches = setting.id.match( /^nav_menu_item\[(-?\d+)]$/ ); - if ( matches ) { - setting.navMenuItemId = parseInt( matches[1], 10 ); - setting.bind( this.onChangeNavMenuItemSetting ); - return true; - } - - matches = setting.id.match( /^nav_menu_locations\[(.+?)]/ ); - if ( matches ) { - themeLocation = matches[1]; - setting.bind( _.bind( function() { - this.refreshMenuLocation( themeLocation ); - }, this ) ); - return true; - } - - return false; - }, - - /** - * Handle changing of a nav_menu setting. - * - * @this {wp.customize.Setting} - */ - onChangeNavMenuSetting : function() { - var setting = this; - if ( ! setting.navMenuId ) { - throw new Error( 'Expected navMenuId property to be set.' ); - } - api.MenusCustomizerPreview.refreshMenu( setting.navMenuId ); - }, + } ); + }; - /** - * Handle changing of a nav_menu_item setting. - * - * @this {wp.customize.Setting} - * @param {object} to - * @param {object} from - */ - onChangeNavMenuItemSetting : function( to, from ) { - if ( from && from.nav_menu_term_id && ( ! to || from.nav_menu_term_id !== to.nav_menu_term_id ) ) { - api.MenusCustomizerPreview.refreshMenu( from.nav_menu_term_id ); - } - if ( to && to.nav_menu_term_id ) { - api.MenusCustomizerPreview.refreshMenu( to.nav_menu_term_id ); - } - }, + if ( api.selectiveRefresh ) { /** - * Update a given menu rendered in the preview. + * Partial representing an invocation of wp_nav_menu(). * - * @param {int} menuId + * @class + * @augments wp.customize.selectiveRefresh.Partial + * @since 4.5.0 */ - refreshMenu : function( menuId ) { - var assignedLocations = []; - - api.each(function( setting, id ) { - var matches = id.match( /^nav_menu_locations\[(.+?)]/ ); - if ( matches && menuId === setting() ) { - assignedLocations.push( matches[1] ); + self.NavMenuInstancePartial = api.selectiveRefresh.Partial.extend({ + + /** + * Constructor. + * + * @since 4.5.0 + * @param {string} id - Partial ID. + * @param {Object} options + * @param {Object} options.params + * @param {Object} options.params.navMenuArgs + * @param {string} options.params.navMenuArgs.args_hmac + * @param {string} [options.params.navMenuArgs.theme_location] + * @param {number} [options.params.navMenuArgs.menu] + * @param {object} [options.constructingContainerContext] + */ + initialize: function( id, options ) { + var partial = this, matches, argsHmac; + matches = id.match( /^nav_menu_instance\[([0-9a-f]{32})]$/ ); + if ( ! matches ) { + throw new Error( 'Illegal id for nav_menu_instance partial. The key corresponds with the args HMAC.' ); } - }); - - _.each( settings.navMenuInstanceArgs, function( navMenuArgs, instanceNumber ) { - if ( menuId === navMenuArgs.menu || -1 !== _.indexOf( assignedLocations, navMenuArgs.theme_location ) ) { - this.refreshMenuInstanceDebounced( instanceNumber ); + argsHmac = matches[1]; + + options = options || {}; + options.params = _.extend( + { + selector: '[data-customize-partial-id="' + id + '"]', + navMenuArgs: options.constructingContainerContext || {}, + containerInclusive: true + }, + options.params || {} + ); + api.selectiveRefresh.Partial.prototype.initialize.call( partial, id, options ); + + if ( ! _.isObject( partial.params.navMenuArgs ) ) { + throw new Error( 'Missing navMenuArgs' ); } - }, this ); - }, - - /** - * Refresh the menu(s) associated with a given nav menu location. - * - * @param {string} location - */ - refreshMenuLocation : function( location ) { - var foundInstance = false; - _.each( settings.navMenuInstanceArgs, function( navMenuArgs, instanceNumber ) { - if ( location === navMenuArgs.theme_location ) { - this.refreshMenuInstanceDebounced( instanceNumber ); - foundInstance = true; + if ( partial.params.navMenuArgs.args_hmac !== argsHmac ) { + throw new Error( 'args_hmac mismatch with id' ); } - }, this ); - if ( ! foundInstance ) { - api.preview.send( 'refresh' ); - } - }, - - /** - * Update a specific instance of a given menu on the page. - * - * @param {int} instanceNumber - */ - refreshMenuInstance : function( instanceNumber ) { - var data, menuId, customized, container, request, wpNavMenuArgs, instance, containerInstanceClassName; - - if ( ! settings.navMenuInstanceArgs[ instanceNumber ] ) { - throw new Error( 'unknown_instance_number' ); - } - instance = settings.navMenuInstanceArgs[ instanceNumber ]; - - containerInstanceClassName = 'partial-refreshable-nav-menu-' + String( instanceNumber ); - container = $( '.' + containerInstanceClassName ); - - if ( _.isNumber( instance.menu ) ) { - menuId = instance.menu; - } else if ( instance.theme_location && api.has( 'nav_menu_locations[' + instance.theme_location + ']' ) ) { - menuId = api( 'nav_menu_locations[' + instance.theme_location + ']' ).get(); - } - - if ( ! menuId || ! instance.can_partial_refresh || 0 === container.length ) { - api.preview.send( 'refresh' ); - return; - } - menuId = parseInt( menuId, 10 ); - - data = { - nonce: wp.customize.settings.nonce.preview, - wp_customize: 'on' - }; - if ( ! wp.customize.settings.theme.active ) { - data.theme = wp.customize.settings.theme.stylesheet; - } - data[ settings.renderQueryVar ] = '1'; - - // Gather settings to send in partial refresh request. - customized = {}; - api.each( function( setting, id ) { - var value = setting.get(), shouldSend = false; - // @todo Core should propagate the dirty state into the Preview as well so we can use that here. - - // Send setting if it is a nav_menu_locations[] setting. - shouldSend = shouldSend || /^nav_menu_locations\[/.test( id ); - - // Send setting if it is the setting for this menu. - shouldSend = shouldSend || id === 'nav_menu[' + String( menuId ) + ']'; - - // Send setting if it is one that is associated with this menu, or it is deleted. - shouldSend = shouldSend || ( /^nav_menu_item\[/.test( id ) && ( false === value || menuId === value.nav_menu_term_id ) ); - - if ( shouldSend ) { - customized[ id ] = value; + }, + + /** + * Return whether the setting is related to this partial. + * + * @since 4.5.0 + * @param {wp.customize.Value|string} setting - Object or ID. + * @param {number|object|false|null} newValue - New value, or null if the setting was just removed. + * @param {number|object|false|null} oldValue - Old value, or null if the setting was just added. + * @returns {boolean} + */ + isRelatedSetting: function( setting, newValue, oldValue ) { + var partial = this, navMenuLocationSetting, navMenuId; + if ( _.isString( setting ) ) { + setting = api( setting ); } - } ); - data.customized = JSON.stringify( customized ); - data[ settings.renderNoncePostKey ] = settings.renderNonceValue; - wpNavMenuArgs = $.extend( {}, instance ); - data.wp_nav_menu_args_hash = wpNavMenuArgs.args_hash; - delete wpNavMenuArgs.args_hash; - data.wp_nav_menu_args = JSON.stringify( wpNavMenuArgs ); - - container.addClass( 'customize-partial-refreshing' ); - - request = wp.ajax.send( null, { - data: data, - url: api.settings.url.self - } ); - request.done( function( data ) { - // If the menu is now not visible, refresh since the page layout may have changed. - if ( false === data ) { - api.preview.send( 'refresh' ); - return; + if ( partial.params.navMenuArgs.theme_location ) { + if ( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' === setting.id ) { + return true; + } + navMenuLocationSetting = api( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ); } - var eventParam, previousContainer = container; - container = $( data ); - container.addClass( containerInstanceClassName ); - container.addClass( 'partial-refreshable-nav-menu customize-partial-refreshing' ); - previousContainer.replaceWith( container ); - eventParam = { - instanceNumber: instanceNumber, - wpNavArgs: wpNavMenuArgs, // @deprecated - wpNavMenuArgs: wpNavMenuArgs, - oldContainer: previousContainer, - newContainer: container - }; - container.removeClass( 'customize-partial-refreshing' ); - $( document ).trigger( 'customize-preview-menu-refreshed', [ eventParam ] ); - } ); - request.fail( function() { - api.preview.send( 'refresh' ); - } ); - }, + navMenuId = partial.params.navMenuArgs.menu; + if ( ! navMenuId && navMenuLocationSetting ) { + navMenuId = navMenuLocationSetting(); + } - refreshMenuInstanceDebounced : function( instanceNumber ) { - if ( currentRefreshDebounced[ instanceNumber ] ) { - clearTimeout( currentRefreshDebounced[ instanceNumber ] ); + if ( ! navMenuId ) { + return false; + } + return ( + ( 'nav_menu[' + navMenuId + ']' === setting.id ) || + ( /^nav_menu_item\[/.test( setting.id ) && + ( ( newValue && newValue.nav_menu_term_id === navMenuId ) || ( oldValue && oldValue.nav_menu_term_id === navMenuId ) ) + ) + ); + }, + + /** + * Render content. + * + * @inheritdoc + * @param {wp.customize.selectiveRefresh.Placement} placement + */ + renderContent: function( placement ) { + var partial = this, previousContainer = placement.container; + if ( api.selectiveRefresh.Partial.prototype.renderContent.call( partial, placement ) ) { + + // Trigger deprecated event. + $( document ).trigger( 'customize-preview-menu-refreshed', [ { + instanceNumber: null, // @deprecated + wpNavArgs: placement.context, // @deprecated + wpNavMenuArgs: placement.context, + oldContainer: previousContainer, + newContainer: placement.container + } ] ); + } } - currentRefreshDebounced[ instanceNumber ] = setTimeout( - _.bind( function() { - this.refreshMenuInstance( instanceNumber ); - }, this ), - refreshDebounceDelay - ); - }, + }); + + api.selectiveRefresh.partialConstructor.nav_menu_instance = self.NavMenuInstancePartial; /** - * Connect nav menu items with their corresponding controls in the pane. + * Watch for changes to nav_menu_locations[] settings. + * + * Refresh partials associated with the given nav_menu_locations[] setting, + * or request an entire preview refresh if there are no containers in the + * document for a partial associated with the theme location. + * + * @since 4.5.0 */ - highlightControls: function() { - var selector = '.menu-item', - addTooltips; - - // Open expand the menu item control when shift+clicking the menu item - $( document ).on( 'click', selector, function( e ) { - var navMenuItemParts; - if ( ! e.shiftKey ) { + self.watchNavMenuLocationChanges = function() { + api.bind( 'change', function( setting ) { + var themeLocation, themeLocationPartialFound = false, matches = setting.id.match( /^nav_menu_locations\[(.+)]$/ ); + if ( ! matches ) { return; } + themeLocation = matches[1]; + api.selectiveRefresh.partial.each( function( partial ) { + if ( partial.extended( self.NavMenuInstancePartial ) && partial.params.navMenuArgs.theme_location === themeLocation ) { + partial.refresh(); + themeLocationPartialFound = true; + } + } ); - navMenuItemParts = $( this ).attr( 'class' ).match( /(?:^|\s)menu-item-(\d+)(?:\s|$)/ ); - if ( navMenuItemParts ) { - e.preventDefault(); - e.stopPropagation(); // Make sure a sub-nav menu item will get focused instead of parent items. - api.preview.send( 'focus-nav-menu-item-control', parseInt( navMenuItemParts[1], 10 ) ); + if ( ! themeLocationPartialFound ) { + api.selectiveRefresh.requestFullRefresh(); } - }); - - addTooltips = function( e, params ) { - params.newContainer.find( selector ).attr( 'title', settings.l10n.editNavMenuItemTooltip ); - }; + } ); + }; + } + + /** + * Connect nav menu items with their corresponding controls in the pane. + * + * Setup shift-click on nav menu items which are more granular than the nav menu partial itself. + * Also this applies even if a nav menu is not partial-refreshable. + * + * @since 4.5.0 + */ + self.highlightControls = function() { + var selector = '.menu-item'; + + // Focus on the menu item control when shift+clicking the menu item. + $( document ).on( 'click', selector, function( e ) { + var navMenuItemParts; + if ( ! e.shiftKey ) { + return; + } - addTooltips( null, { newContainer: $( document.body ) } ); - $( document ).on( 'customize-preview-menu-refreshed', addTooltips ); - } + navMenuItemParts = $( this ).attr( 'class' ).match( /(?:^|\s)menu-item-(\d+)(?:\s|$)/ ); + if ( navMenuItemParts ) { + e.preventDefault(); + e.stopPropagation(); // Make sure a sub-nav menu item will get focused instead of parent items. + api.preview.send( 'focus-nav-menu-item-control', parseInt( navMenuItemParts[1], 10 ) ); + } + }); }; api.bind( 'preview-ready', function() { - api.preview.bind( 'active', function() { - api.MenusCustomizerPreview.init(); - } ); + self.init(); } ); -}( jQuery, _, wp ) ); + return self; + +}( jQuery, _, wp, wp.customize ) ); diff --git src/wp-includes/js/customize-preview-widgets.js src/wp-includes/js/customize-preview-widgets.js index f982829..92e7732 100644 --- src/wp-includes/js/customize-preview-widgets.js +++ src/wp-includes/js/customize-preview-widgets.js @@ -1,119 +1,648 @@ -(function( wp, $ ){ +/* global _wpWidgetCustomizerPreviewSettings */ +wp.customize.widgetsPreview = wp.customize.WidgetCustomizerPreview = (function( $, _, wp, api ) { - if ( ! wp || ! wp.customize ) { return; } + var self; - var api = wp.customize; + self = { + renderedSidebars: {}, + renderedWidgets: {}, + registeredSidebars: [], + registeredWidgets: {}, + widgetSelectors: [], + preview: null, + l10n: { + widgetTooltip: '' + } + }; /** - * wp.customize.WidgetCustomizerPreview + * Init widgets preview. * + * @since 4.5.0 */ - api.WidgetCustomizerPreview = { - renderedSidebars: {}, // @todo Make rendered a property of the Backbone model - renderedWidgets: {}, // @todo Make rendered a property of the Backbone model - registeredSidebars: [], // @todo Make a Backbone collection - registeredWidgets: {}, // @todo Make array, Backbone collection - widgetSelectors: [], - preview: null, - l10n: {}, + self.init = function() { + var self = this; - init: function () { - var self = this; + self.preview = api.preview; + if ( api.selectiveRefresh ) { + self.addPartials(); + } - this.preview = api.preview; - this.buildWidgetSelectors(); - this.highlightControls(); + self.buildWidgetSelectors(); + self.highlightControls(); - this.preview.bind( 'highlight-widget', self.highlightWidget ); - }, + self.preview.bind( 'highlight-widget', self.highlightWidget ); + + api.preview.bind( 'active', function() { + self.highlightControls(); + } ); + }; + + if ( api.selectiveRefresh ) { /** - * Calculate the selector for the sidebar's widgets based on the registered sidebar's info + * Partial representing a widget instance. + * + * @class + * @augments wp.customize.selectiveRefresh.Partial + * @since 4.5.0 */ - buildWidgetSelectors: function () { - var self = this; - - $.each( this.registeredSidebars, function ( i, sidebar ) { - var widgetTpl = [ - sidebar.before_widget.replace('%1$s', '').replace('%2$s', ''), - sidebar.before_title, - sidebar.after_title, - sidebar.after_widget - ].join(''), - emptyWidget, - widgetSelector, - widgetClasses; - - emptyWidget = $(widgetTpl); - widgetSelector = emptyWidget.prop('tagName'); - widgetClasses = emptyWidget.prop('className'); - - // Prevent a rare case when before_widget, before_title, after_title and after_widget is empty. - if ( ! widgetClasses ) { - return; + self.WidgetPartial = api.selectiveRefresh.Partial.extend({ + + /** + * Constructor. + * + * @since 4.5.0 + * @param {string} id - Partial ID. + * @param {Object} options + * @param {Object} options.params + */ + initialize: function( id, options ) { + var partial = this, matches; + matches = id.match( /^widget\[(.+)]$/ ); + if ( ! matches ) { + throw new Error( 'Illegal id for widget partial.' ); } - widgetClasses = widgetClasses.replace(/^\s+|\s+$/g, ''); + partial.widgetId = matches[1]; + options = options || {}; + options.params = _.extend( + { + /* Note that a selector of ('#' + partial.widgetId) is faster, but jQuery will only return the one result. */ + selector: '[id="' + partial.widgetId + '"]', // Alternatively, '[data-customize-widget-id="' + partial.widgetId + '"]' + settings: [ self.getWidgetSettingId( partial.widgetId ) ], + containerInclusive: true + }, + options.params || {} + ); + + api.selectiveRefresh.Partial.prototype.initialize.call( partial, id, options ); + }, - if ( widgetClasses ) { - widgetSelector += '.' + widgetClasses.split(/\s+/).join('.'); + /** + * Send widget-updated message to parent so spinner will get removed from widget control. + * + * @inheritdoc + * @param {wp.customize.selectiveRefresh.Placement} placement + */ + renderContent: function( placement ) { + var partial = this; + if ( api.selectiveRefresh.Partial.prototype.renderContent.call( partial, placement ) ) { + api.preview.send( 'widget-updated', partial.widgetId ); + api.selectiveRefresh.trigger( 'widget-updated', partial ); } - self.widgetSelectors.push(widgetSelector); - }); - }, + } + }); /** - * Highlight the widget on widget updates or widget control mouse overs. + * Partial representing a widget area. * - * @param {string} widgetId ID of the widget. + * @class + * @augments wp.customize.selectiveRefresh.Partial + * @since 4.5.0 */ - highlightWidget: function( widgetId ) { - var $body = $( document.body ), - $widget = $( '#' + widgetId ); + self.SidebarPartial = api.selectiveRefresh.Partial.extend({ - $body.find( '.widget-customizer-highlighted-widget' ).removeClass( 'widget-customizer-highlighted-widget' ); + /** + * Constructor. + * + * @since 4.5.0 + * @param {string} id - Partial ID. + * @param {Object} options + * @param {Object} options.params + */ + initialize: function( id, options ) { + var partial = this, matches; + matches = id.match( /^sidebar\[(.+)]$/ ); + if ( ! matches ) { + throw new Error( 'Illegal id for sidebar partial.' ); + } + partial.sidebarId = matches[1]; - $widget.addClass( 'widget-customizer-highlighted-widget' ); - setTimeout( function () { - $widget.removeClass( 'widget-customizer-highlighted-widget' ); - }, 500 ); - }, + options = options || {}; + options.params = _.extend( + { + settings: [ 'sidebars_widgets[' + partial.sidebarId + ']' ] + }, + options.params || {} + ); - /** - * Show a title and highlight widgets on hover. On shift+clicking - * focus the widget control. - */ - highlightControls: function() { - var self = this, - selector = this.widgetSelectors.join(','); + api.selectiveRefresh.Partial.prototype.initialize.call( partial, id, options ); + + if ( ! partial.params.sidebarArgs ) { + throw new Error( 'The sidebarArgs param was not provided.' ); + } + if ( partial.params.settings.length > 1 ) { + throw new Error( 'Expected SidebarPartial to only have one associated setting' ); + } + }, + + /** + * Set up the partial. + * + * @since 4.5.0 + */ + ready: function() { + var sidebarPartial = this; + + // Watch for changes to the sidebar_widgets setting. + _.each( sidebarPartial.settings(), function( settingId ) { + api( settingId ).bind( _.bind( sidebarPartial.handleSettingChange, sidebarPartial ) ); + } ); + + // Trigger an event for this sidebar being updated whenever a widget inside is rendered. + api.selectiveRefresh.bind( 'partial-content-rendered', function( placement ) { + var isAssignedWidgetPartial = ( + placement.partial.extended( self.WidgetPartial ) && + ( -1 !== _.indexOf( sidebarPartial.getWidgetIds(), placement.partial.widgetId ) ) + ); + if ( isAssignedWidgetPartial ) { + api.selectiveRefresh.trigger( 'sidebar-updated', sidebarPartial ); + } + } ); + + // Make sure that a widget partial has a container in the DOM prior to a refresh. + api.bind( 'change', function( widgetSetting ) { + var widgetId, parsedId; + parsedId = self.parseWidgetSettingId( widgetSetting.id ); + if ( ! parsedId ) { + return; + } + widgetId = parsedId.idBase; + if ( parsedId.number ) { + widgetId += '-' + String( parsedId.number ); + } + if ( -1 !== _.indexOf( sidebarPartial.getWidgetIds(), widgetId ) ) { + sidebarPartial.ensureWidgetPlacementContainers( widgetId ); + } + } ); + }, + + /** + * Get the before/after boundary nodes for all instances of this sidebar (usually one). + * + * Note that TreeWalker is not implemented in IE8. + * + * @since 4.5.0 + * @returns {Array.<{before: Comment, after: Comment, instanceNumber: number}>} + */ + findDynamicSidebarBoundaryNodes: function() { + var partial = this, regExp, boundaryNodes = {}, recursiveCommentTraversal; + regExp = /^(dynamic_sidebar_before|dynamic_sidebar_after):(.+):(\d+)$/; + recursiveCommentTraversal = function( childNodes ) { + _.each( childNodes, function( node ) { + var matches; + if ( 8 === node.nodeType ) { + matches = node.nodeValue.match( regExp ); + if ( ! matches || matches[2] !== partial.sidebarId ) { + return; + } + if ( _.isUndefined( boundaryNodes[ matches[3] ] ) ) { + boundaryNodes[ matches[3] ] = { + before: null, + after: null, + instanceNumber: parseInt( matches[3], 10 ) + }; + } + if ( 'dynamic_sidebar_before' === matches[1] ) { + boundaryNodes[ matches[3] ].before = node; + } else { + boundaryNodes[ matches[3] ].after = node; + } + } else if ( 1 === node.nodeType ) { + recursiveCommentTraversal( node.childNodes ); + } + } ); + }; + + recursiveCommentTraversal( document.body.childNodes ); + return _.values( boundaryNodes ); + }, + + /** + * Get the placements for this partial. + * + * @since 4.5.0 + * @returns {Array} + */ + placements: function() { + var partial = this; + return _.map( partial.findDynamicSidebarBoundaryNodes(), function( boundaryNodes ) { + return new api.selectiveRefresh.Placement( { + partial: partial, + container: null, + startNode: boundaryNodes.before, + endNode: boundaryNodes.after, + context: { + instanceNumber: boundaryNodes.instanceNumber + } + } ); + } ); + }, + + /** + * Get the list of widget IDs associated with this widget area. + * + * @since 4.5.0 + * + * @returns {Array} + */ + getWidgetIds: function() { + var sidebarPartial = this, settingId, widgetIds; + settingId = sidebarPartial.settings()[0]; + if ( ! settingId ) { + throw new Error( 'Missing associated setting.' ); + } + if ( ! api.has( settingId ) ) { + throw new Error( 'Setting does not exist.' ); + } + widgetIds = api( settingId ).get(); + if ( ! _.isArray( widgetIds ) ) { + throw new Error( 'Expected setting to be array of widget IDs' ); + } + return widgetIds.slice( 0 ); + }, + + /** + * Reflow widgets in the sidebar, ensuring they have the proper position in the DOM. + * + * @since 4.5.0 + * + * @return {Array.} List of placements that were reflowed. + */ + reflowWidgets: function() { + var sidebarPartial = this, sidebarPlacements, widgetIds, widgetPartials, sortedSidebarContainers = []; + widgetIds = sidebarPartial.getWidgetIds(); + sidebarPlacements = sidebarPartial.placements(); + + widgetPartials = {}; + _.each( widgetIds, function( widgetId ) { + var widgetPartial = api.selectiveRefresh.partial( 'widget[' + widgetId + ']' ); + if ( widgetPartial ) { + widgetPartials[ widgetId ] = widgetPartial; + } + } ); + + _.each( sidebarPlacements, function( sidebarPlacement ) { + var sidebarWidgets = [], needsSort = false, thisPosition, lastPosition = -1; + + // Gather list of widget partial containers in this sidebar, and determine if a sort is needed. + _.each( widgetPartials, function( widgetPartial ) { + _.each( widgetPartial.placements(), function( widgetPlacement ) { + + if ( sidebarPlacement.context.instanceNumber === widgetPlacement.context.sidebar_instance_number ) { + thisPosition = widgetPlacement.container.index(); + sidebarWidgets.push( { + partial: widgetPartial, + placement: widgetPlacement, + position: thisPosition + } ); + if ( thisPosition < lastPosition ) { + needsSort = true; + } + lastPosition = thisPosition; + } + } ); + } ); + + if ( needsSort ) { + _.each( sidebarWidgets, function( sidebarWidget ) { + sidebarPlacement.endNode.parentNode.insertBefore( + sidebarWidget.placement.container[0], + sidebarPlacement.endNode + ); + + // @todo Rename partial-placement-moved? + api.selectiveRefresh.trigger( 'partial-content-moved', sidebarWidget.placement ); + } ); + + sortedSidebarContainers.push( sidebarPlacement ); + } + } ); + + if ( sortedSidebarContainers.length > 0 ) { + api.selectiveRefresh.trigger( 'sidebar-updated', sidebarPartial ); + } + + return sortedSidebarContainers; + }, + + /** + * Make sure there is a widget instance container in this sidebar for the given widget ID. + * + * @since 4.5.0 + * + * @param {string} widgetId + * @returns {wp.customize.selectiveRefresh.Partial} Widget instance partial. + */ + ensureWidgetPlacementContainers: function( widgetId ) { + var sidebarPartial = this, widgetPartial, wasInserted = false, partialId = 'widget[' + widgetId + ']'; + widgetPartial = api.selectiveRefresh.partial( partialId ); + if ( ! widgetPartial ) { + widgetPartial = new self.WidgetPartial( partialId, { + params: {} + } ); + api.selectiveRefresh.partial.add( widgetPartial.id, widgetPartial ); + } - $(selector).attr( 'title', this.l10n.widgetTooltip ); + // Make sure that there is a container element for the widget in the sidebar, if at least a placeholder. + _.each( sidebarPartial.placements(), function( sidebarPlacement ) { + var foundWidgetPlacement, widgetContainerElement; - $(document).on( 'mouseenter', selector, function () { - self.preview.send( 'highlight-widget-control', $( this ).prop( 'id' ) ); - }); + foundWidgetPlacement = _.find( widgetPartial.placements(), function( widgetPlacement ) { + return ( widgetPlacement.context.sidebar_instance_number === sidebarPlacement.context.instanceNumber ); + } ); + if ( foundWidgetPlacement ) { + return; + } - // Open expand the widget control when shift+clicking the widget element - $(document).on( 'click', selector, function ( e ) { - if ( ! e.shiftKey ) { + widgetContainerElement = $( + sidebarPartial.params.sidebarArgs.before_widget.replace( '%1$s', widgetId ).replace( '%2$s', 'widget' ) + + sidebarPartial.params.sidebarArgs.after_widget + ); + + widgetContainerElement.attr( 'data-customize-partial-id', widgetPartial.id ); + widgetContainerElement.attr( 'data-customize-partial-type', 'widget' ); + widgetContainerElement.attr( 'data-customize-widget-id', widgetId ); + + /* + * Make sure the widget container element has the customize-container context data. + * The sidebar_instance_number is used to disambiguate multiple instances of the + * same sidebar are rendered onto the template, and so the same widget is embedded + * multiple times. + */ + widgetContainerElement.data( 'customize-partial-placement-context', { + 'sidebar_id': sidebarPartial.sidebarId, + 'sidebar_instance_number': sidebarPlacement.context.instanceNumber + } ); + + sidebarPlacement.endNode.parentNode.insertBefore( widgetContainerElement[0], sidebarPlacement.endNode ); + wasInserted = true; + } ); + + if ( wasInserted ) { + sidebarPartial.reflowWidgets(); + } + + return widgetPartial; + }, + + /** + * Handle change to the sidebars_widgets[] setting. + * + * @since 4.5.0 + * + * @param {Array} newWidgetIds New widget ids. + * @param {Array} oldWidgetIds Old widget ids. + */ + handleSettingChange: function( newWidgetIds, oldWidgetIds ) { + var sidebarPartial = this, needsRefresh, widgetsRemoved, widgetsAdded, addedWidgetPartials = []; + + needsRefresh = ( + ( oldWidgetIds.length > 0 && 0 === newWidgetIds.length ) || + ( newWidgetIds.length > 0 && 0 === oldWidgetIds.length ) + ); + if ( needsRefresh ) { + sidebarPartial.fallback(); return; } - e.preventDefault(); - self.preview.send( 'focus-widget-control', $( this ).prop( 'id' ) ); - }); + // Handle removal of widgets. + widgetsRemoved = _.difference( oldWidgetIds, newWidgetIds ); + _.each( widgetsRemoved, function( removedWidgetId ) { + var widgetPartial = api.selectiveRefresh.partial( 'widget[' + removedWidgetId + ']' ); + if ( widgetPartial ) { + _.each( widgetPartial.placements(), function( placement ) { + var isRemoved = ( + placement.context.sidebar_id === sidebarPartial.sidebarId || + ( placement.context.sidebar_args && placement.context.sidebar_args.id === sidebarPartial.sidebarId ) + ); + if ( isRemoved ) { + placement.container.remove(); + } + } ); + } + } ); + + // Handle insertion of widgets. + widgetsAdded = _.difference( newWidgetIds, oldWidgetIds ); + _.each( widgetsAdded, function( addedWidgetId ) { + var widgetPartial = sidebarPartial.ensureWidgetPlacementContainers( addedWidgetId ); + addedWidgetPartials.push( widgetPartial ); + } ); + + _.each( addedWidgetPartials, function( widgetPartial ) { + widgetPartial.refresh(); + } ); + + api.selectiveRefresh.trigger( 'sidebar-updated', sidebarPartial ); + }, + + /** + * Note that the meat is handled in handleSettingChange because it has the context of which widgets were removed. + * + * @since 4.5.0 + */ + refresh: function() { + var partial = this, deferred = $.Deferred(); + + deferred.fail( function() { + partial.fallback(); + } ); + + if ( 0 === partial.placements().length ) { + deferred.reject(); + } else { + _.each( partial.reflowWidgets(), function( sidebarPlacement ) { + api.selectiveRefresh.trigger( 'partial-content-rendered', sidebarPlacement ); + } ); + deferred.resolve(); + } + + return deferred.promise(); + } + }); + + api.selectiveRefresh.partialConstructor.sidebar = self.SidebarPartial; + api.selectiveRefresh.partialConstructor.widget = self.WidgetPartial; + + /** + * Add partials for the registered widget areas (sidebars). + * + * @since 4.5.0 + */ + self.addPartials = function() { + _.each( self.registeredSidebars, function( registeredSidebar ) { + var partial, partialId = 'sidebar[' + registeredSidebar.id + ']'; + partial = api.selectiveRefresh.partial( partialId ); + if ( ! partial ) { + partial = new self.SidebarPartial( partialId, { + params: { + sidebarArgs: registeredSidebar + } + } ); + api.selectiveRefresh.partial.add( partial.id, partial ); + } + } ); + }; + + } + + /** + * Calculate the selector for the sidebar's widgets based on the registered sidebar's info. + * + * @since 3.9.0 + */ + self.buildWidgetSelectors = function() { + var self = this; + + $.each( self.registeredSidebars, function( i, sidebar ) { + var widgetTpl = [ + sidebar.before_widget.replace( '%1$s', '' ).replace( '%2$s', '' ), + sidebar.before_title, + sidebar.after_title, + sidebar.after_widget + ].join( '' ), + emptyWidget, + widgetSelector, + widgetClasses; + + emptyWidget = $( widgetTpl ); + widgetSelector = emptyWidget.prop( 'tagName' ); + widgetClasses = emptyWidget.prop( 'className' ); + + // Prevent a rare case when before_widget, before_title, after_title and after_widget is empty. + if ( ! widgetClasses ) { + return; + } + + widgetClasses = widgetClasses.replace( /^\s+|\s+$/g, '' ); + + if ( widgetClasses ) { + widgetSelector += '.' + widgetClasses.split( /\s+/ ).join( '.' ); + } + self.widgetSelectors.push( widgetSelector ); + }); + }; + + /** + * Highlight the widget on widget updates or widget control mouse overs. + * + * @since 3.9.0 + * @param {string} widgetId ID of the widget. + */ + self.highlightWidget = function( widgetId ) { + var $body = $( document.body ), + $widget = $( '#' + widgetId ); + + $body.find( '.widget-customizer-highlighted-widget' ).removeClass( 'widget-customizer-highlighted-widget' ); + + $widget.addClass( 'widget-customizer-highlighted-widget' ); + setTimeout( function() { + $widget.removeClass( 'widget-customizer-highlighted-widget' ); + }, 500 ); + }; + + /** + * Show a title and highlight widgets on hover. On shift+clicking + * focus the widget control. + * + * @since 3.9.0 + */ + self.highlightControls = function() { + var self = this, + selector = this.widgetSelectors.join( ',' ); + + $( selector ).attr( 'title', this.l10n.widgetTooltip ); + + $( document ).on( 'mouseenter', selector, function() { + self.preview.send( 'highlight-widget-control', $( this ).prop( 'id' ) ); + }); + + // Open expand the widget control when shift+clicking the widget element + $( document ).on( 'click', selector, function( e ) { + if ( ! e.shiftKey ) { + return; + } + e.preventDefault(); + + self.preview.send( 'focus-widget-control', $( this ).prop( 'id' ) ); + }); + }; + + /** + * Parse a widget ID. + * + * @since 4.5.0 + * + * @param {string} widgetId Widget ID. + * @returns {{idBase: string, number: number|null}} + */ + self.parseWidgetId = function( widgetId ) { + var matches, parsed = { + idBase: '', + number: null + }; + + matches = widgetId.match( /^(.+)-(\d+)$/ ); + if ( matches ) { + parsed.idBase = matches[1]; + parsed.number = parseInt( matches[2], 10 ); + } else { + parsed.idBase = widgetId; // Likely an old single widget. + } + + return parsed; + }; + + /** + * Parse a widget setting ID. + * + * @since 4.5.0 + * + * @param {string} settingId Widget setting ID. + * @returns {{idBase: string, number: number|null}|null} + */ + self.parseWidgetSettingId = function( settingId ) { + var matches, parsed = { + idBase: '', + number: null + }; + + matches = settingId.match( /^widget_([^\[]+?)(?:\[(\d+)])?$/ ); + if ( ! matches ) { + return null; } + parsed.idBase = matches[1]; + if ( matches[2] ) { + parsed.number = parseInt( matches[2], 10 ); + } + return parsed; }; - $(function () { - var settings = window._wpWidgetCustomizerPreviewSettings; - if ( ! settings ) { - return; + /** + * Convert a widget ID into a Customizer setting ID. + * + * @since 4.5.0 + * + * @param {string} widgetId Widget ID. + * @returns {string} settingId Setting ID. + */ + self.getWidgetSettingId = function( widgetId ) { + var parsed = this.parseWidgetId( widgetId ), settingId; + + settingId = 'widget_' + parsed.idBase; + if ( parsed.number ) { + settingId += '[' + String( parsed.number ) + ']'; } - $.extend( api.WidgetCustomizerPreview, settings ); + return settingId; + }; - api.WidgetCustomizerPreview.init(); + api.bind( 'preview-ready', function() { + $.extend( self, _wpWidgetCustomizerPreviewSettings ); + self.init(); }); -})( window.wp, jQuery ); + return self; +})( jQuery, _, wp, wp.customize ); diff --git src/wp-includes/js/customize-selective-refresh.js src/wp-includes/js/customize-selective-refresh.js new file mode 100644 index 0000000..3da6144 --- /dev/null +++ src/wp-includes/js/customize-selective-refresh.js @@ -0,0 +1,849 @@ +/* global jQuery, JSON, _customizePartialRefreshExports, console */ + +wp.customize.selectiveRefresh = ( function( $, api ) { + 'use strict'; + var self, Partial, Placement; + + self = { + ready: $.Deferred(), + data: { + partials: {}, + renderQueryVar: '', + l10n: { + shiftClickToEdit: '' + }, + refreshBuffer: 250 + }, + currentRequest: null + }; + + _.extend( self, api.Events ); + + /** + * A Customizer Partial. + * + * A partial provides a rendering of one or more settings according to a template. + * + * @see PHP class WP_Customize_Partial. + * + * @class + * @augments wp.customize.Class + * @since 4.5.0 + * + * @param {string} id Unique identifier for the control instance. + * @param {object} options Options hash for the control instance. + * @param {object} options.params + * @param {string} options.params.type Type of partial (e.g. nav_menu, widget, etc) + * @param {string} options.params.selector jQuery selector to find the container element in the page. + * @param {array} options.params.settings The IDs for the settings the partial relates to. + * @param {string} options.params.primarySetting The ID for the primary setting the partial renders. + * @param {bool} options.params.fallbackRefresh Whether to refresh the entire preview in case of a partial refresh failure. + */ + Partial = self.Partial = api.Class.extend({ + + id: null, + + /** + * Constructor. + * + * @since 4.5.0 + * + * @param {string} id - Partial ID. + * @param {Object} options + * @param {Object} options.params + */ + initialize: function( id, options ) { + var partial = this; + options = options || {}; + partial.id = id; + + partial.params = _.extend( + { + selector: null, + settings: [], + primarySetting: null, + containerInclusive: false, + fallbackRefresh: true // Note this needs to be false in a frontend editing context. + }, + options.params || {} + ); + + partial.deferred = {}; + partial.deferred.ready = $.Deferred(); + + partial.deferred.ready.done( function() { + partial.ready(); + } ); + }, + + /** + * Set up the partial. + * + * @since 4.5.0 + */ + ready: function() { + var partial = this; + _.each( _.pluck( partial.placements(), 'container' ), function( container ) { + $( container ).attr( 'title', self.data.l10n.shiftClickToEdit ); + } ); + $( document ).on( 'click', partial.params.selector, function( e ) { + if ( ! e.shiftKey ) { + return; + } + e.preventDefault(); + _.each( partial.placements(), function( placement ) { + if ( $( placement.container ).is( e.currentTarget ) ) { + partial.showControl(); + } + } ); + } ); + }, + + /** + * Find all placements for this partial int he document. + * + * @since 4.5.0 + * + * @return {Array.} + */ + placements: function() { + var partial = this, selector; + + selector = partial.params.selector; + if ( selector ) { + selector += ', '; + } + selector += '[data-customize-partial-id="' + partial.id + '"]'; // @todo Consider injecting customize-partial-id-${id} classnames instead. + + return $( selector ).map( function() { + var container = $( this ), context; + + context = container.data( 'customize-partial-placement-context' ); + if ( _.isString( context ) && '{' === context.substr( 0, 1 ) ) { + throw new Error( 'context JSON parse error' ); + } + + return new Placement( { + partial: partial, + container: container, + context: context + } ); + } ).get(); + }, + + /** + * Get list of setting IDs related to this partial. + * + * @since 4.5.0 + * + * @return {String[]} + */ + settings: function() { + var partial = this; + if ( partial.params.settings && 0 !== partial.params.settings.length ) { + return partial.params.settings; + } else if ( partial.params.primarySetting ) { + return [ partial.params.primarySetting ]; + } else { + return [ partial.id ]; + } + }, + + /** + * Return whether the setting is related to the partial. + * + * @since 4.5.0 + * + * @param {wp.customize.Value|string} setting ID or object for setting. + * @return {boolean} Whether the setting is related to the partial. + */ + isRelatedSetting: function( setting /*... newValue, oldValue */ ) { + var partial = this; + if ( _.isString( setting ) ) { + setting = api( setting ); + } + if ( ! setting ) { + return false; + } + return -1 !== _.indexOf( partial.settings(), setting.id ); + }, + + /** + * Show the control to modify this partial's setting(s). + * + * This may be overridden for inline editing. + * + * @since 4.5.0 + */ + showControl: function() { + var partial = this, settingId = partial.params.primarySetting; + if ( ! settingId ) { + settingId = _.first( partial.settings() ); + } + api.preview.send( 'focus-control-for-setting', settingId ); + }, + + /** + * Prepare container for selective refresh. + * + * @since 4.5.0 + * + * @param {Placement} placement + */ + preparePlacement: function( placement ) { + $( placement.container ).addClass( 'customize-partial-refreshing' ); + }, + + /** + * Reference to the pending promise returned from self.requestPartial(). + * + * @since 4.5.0 + * @private + */ + _pendingRefreshPromise: null, + + /** + * Request the new partial and render it into the placements. + * + * @since 4.5.0 + * + * @this {wp.customize.selectiveRefresh.Partial} + * @return {jQuery.Promise} + */ + refresh: function() { + var partial = this, refreshPromise; + + refreshPromise = self.requestPartial( partial ); + + if ( ! partial._pendingRefreshPromise ) { + _.each( partial.placements(), function( placement ) { + partial.preparePlacement( placement ); + } ); + + refreshPromise.done( function( placements ) { + _.each( placements, function( placement ) { + partial.renderContent( placement ); + } ); + } ); + + refreshPromise.fail( function( data, placements ) { + partial.fallback( data, placements ); + } ); + + // Allow new request when this one finishes. + partial._pendingRefreshPromise = refreshPromise; + refreshPromise.always( function() { + partial._pendingRefreshPromise = null; + } ); + } + + return refreshPromise; + }, + + /** + * Apply the addedContent in the placement to the document. + * + * Note the placement object will have its container and removedNodes + * properties updated. + * + * @since 4.5.0 + * + * @param {Placement} placement + * @param {Element|jQuery} [placement.container] - This param will be empty if there was no element matching the selector. + * @param {string|object|boolean} placement.addedContent - Rendered HTML content, a data object for JS templates to render, or false if no render. + * @param {object} [placement.context] - Optional context information about the container. + * @returns {boolean} Whether the rendering was successful and the fallback was not invoked. + */ + renderContent: function( placement ) { + var partial = this, content, newContainerElement; + if ( ! placement.container ) { + partial.fallback( new Error( 'no_container' ), [ placement ] ); + return false; + } + placement.container = $( placement.container ); + if ( false === placement.addedContent ) { + partial.fallback( new Error( 'missing_render' ), [ placement ] ); + return false; + } + + // Currently a subclass needs to override renderContent to handle partials returning data object. + if ( ! _.isString( placement.addedContent ) ) { + partial.fallback( new Error( 'non_string_content' ), [ placement ] ); + return false; + } + + content = placement.addedContent; + if ( wp.emoji && wp.emoji.parse && ! $.contains( document.head, placement.container[0] ) ) { + content = wp.emoji.parse( content ); + } + + // @todo Should containerInclusive be context information as opposed to a param? + if ( partial.params.containerInclusive ) { + + // Note that content may be an empty string, and in this case jQuery will just remove the oldContainer + newContainerElement = $( content ); + + // Merge the new context on top of the old context. + placement.context = _.extend( + placement.context, + newContainerElement.data( 'customize-partial-placement-context' ) || {} + ); + newContainerElement.data( 'customize-partial-placement-context', placement.context ); + + placement.removedNodes = placement.container; + placement.container = newContainerElement; + placement.removedNodes.replaceWith( placement.container ); + placement.container.attr( 'title', self.data.l10n.shiftClickToEdit ); + } else { + placement.removedNodes = document.createDocumentFragment(); + while ( placement.container[0].firstChild ) { + placement.removedNodes.appendChild( placement.container[0].firstChild ); + } + + placement.container.html( content ); + } + + placement.container.removeClass( 'customize-partial-refreshing' ); + + // Prevent placement container from being being re-triggered as being rendered among nested partials. + placement.container.data( 'customize-partial-content-rendered', true ); + + /** + * Announce when a partial's placement has been rendered so that dynamic elements can be re-built. + */ + self.trigger( 'partial-content-rendered', placement ); + return true; + }, + + /** + * Handle fail to render partial. + * + * The first argument is either the failing jqXHR or an Error object, and the second argument is the array of containers. + * + * @since 4.5.0 + */ + fallback: function() { + var partial = this; + if ( partial.params.fallbackRefresh ) { + self.requestFullRefresh(); + } + } + } ); + + /** + * A Placement for a Partial. + * + * A partial placement is the actual physical representation of a partial for a given context. + * It also may have information in relation to how a placement may have just changed. + * The placement is conceptually similar to a DOM Range or MutationRecord. + * + * @class + * @augments wp.customize.Class + * @since 4.5.0 + */ + self.Placement = Placement = api.Class.extend({ + + /** + * The partial with which the container is associated. + * + * @param {wp.customize.selectiveRefresh.Partial} + */ + partial: null, + + /** + * DOM element which contains the placement's contents. + * + * This will be null if the startNode and endNode do not point to the same + * DOM element, such as in the case of a sidebar partial. + * This container element itself will be replaced for partials that + * have containerInclusive param defined as true. + */ + container: null, + + /** + * DOM node for the initial boundary of the placement. + * + * This will normally be the same as endNode since most placements appear as elements. + * This is primarily useful for widget sidebars which do not have intrinsic containers, but + * for which an HTML comment is output before to mark the starting position. + */ + startNode: null, + + /** + * DOM node for the terminal boundary of the placement. + * + * This will normally be the same as startNode since most placements appear as elements. + * This is primarily useful for widget sidebars which do not have intrinsic containers, but + * for which an HTML comment is output before to mark the ending position. + */ + endNode: null, + + /** + * Context data. + * + * This provides information about the placement which is included in the request + * in order to render the partial properly. + * + * @param {object} + */ + context: null, + + /** + * The content for the partial when refreshed. + * + * @param {string} + */ + addedContent: null, + + /** + * DOM node(s) removed when the partial is refreshed. + * + * If the partial is containerInclusive, then the removedNodes will be + * the single Element that was the partial's former placement. If the + * partial is not containerInclusive, then the removedNodes will be a + * documentFragment containing the nodes removed. + * + * @param {Element|DocumentFragment} + */ + removedNodes: null, + + /** + * Constructor. + * + * @since 4.5.0 + * + * @param {object} args + * @param {Partial} args.partial + * @param {jQuery|Element} [args.container] + * @param {Node} [args.startNode] + * @param {Node} [args.endNode] + * @param {object} [args.context] + * @param {string} [args.addedContent] + * @param {jQuery|DocumentFragment} [args.removedNodes] + */ + initialize: function( args ) { + var placement = this; + + args = _.extend( {}, args || {} ); + if ( ! args.partial || ! args.partial.extended( Partial ) ) { + throw new Error( 'Missing partial' ); + } + args.context = args.context || {}; + if ( args.container ) { + args.container = $( args.container ); + } + + _.extend( placement, args ); + } + + }); + + /** + * Mapping of type names to Partial constructor subclasses. + * + * @since 4.5.0 + * + * @type {Object.} + */ + self.partialConstructor = {}; + + self.partial = new api.Values({ defaultConstructor: Partial }); + + /** + * Get the POST vars for a Customizer preview request. + * + * @since 4.5.0 + * @see wp.customize.previewer.query() + * + * @return {object} + */ + self.getCustomizeQuery = function() { + var dirtyCustomized = {}; + api.each( function( value, key ) { + if ( value._dirty ) { + dirtyCustomized[ key ] = value(); + } + } ); + + return { + wp_customize: 'on', + nonce: api.settings.nonce.preview, + theme: api.settings.theme.stylesheet, + customized: JSON.stringify( dirtyCustomized ) + }; + }; + + /** + * Currently-requested partials and their associated deferreds. + * + * @since 4.5.0 + * @type {Object} + */ + self._pendingPartialRequests = {}; + + /** + * Timeout ID for the current requesr, or null if no request is current. + * + * @since 4.5.0 + * @type {number|null} + * @private + */ + self._debouncedTimeoutId = null; + + /** + * Current jqXHR for the request to the partials. + * + * @since 4.5.0 + * @type {jQuery.jqXHR|null} + * @private + */ + self._currentRequest = null; + + /** + * Request full page refresh. + * + * When selective refresh is embedded in the context of frontend editing, this request + * must fail or else changes will be lost, unless transactions are implemented. + * + * @since 4.5.0 + */ + self.requestFullRefresh = function() { + api.preview.send( 'refresh' ); + }; + + /** + * Request a re-rendering of a partial. + * + * @since 4.5.0 + * + * @param {wp.customize.selectiveRefresh.Partial} partial + * @return {jQuery.Promise} + */ + self.requestPartial = function( partial ) { + var partialRequest; + + if ( self._debouncedTimeoutId ) { + clearTimeout( self._debouncedTimeoutId ); + self._debouncedTimeoutId = null; + } + if ( self._currentRequest ) { + self._currentRequest.abort(); + self._currentRequest = null; + } + + partialRequest = self._pendingPartialRequests[ partial.id ]; + if ( ! partialRequest || 'pending' !== partialRequest.deferred.state() ) { + partialRequest = { + deferred: $.Deferred(), + partial: partial + }; + self._pendingPartialRequests[ partial.id ] = partialRequest; + } + + // Prevent leaking partial into debounced timeout callback. + partial = null; + + self._debouncedTimeoutId = setTimeout( + function() { + var data, partialPlacementContexts, partialsPlacements, request; + + self._debouncedTimeoutId = null; + data = self.getCustomizeQuery(); + + /* + * It is key that the containers be fetched exactly at the point of the request being + * made, because the containers need to be mapped to responses by array indices. + */ + partialsPlacements = {}; + + partialPlacementContexts = {}; + + _.each( self._pendingPartialRequests, function( pending, partialId ) { + partialsPlacements[ partialId ] = pending.partial.placements(); + if ( ! self.partial.has( partialId ) ) { + pending.deferred.rejectWith( pending.partial, [ new Error( 'partial_removed' ), partialsPlacements[ partialId ] ] ); + } else { + /* + * Note that this may in fact be an empty array. In that case, it is the responsibility + * of the Partial subclass instance to know where to inject the response, or else to + * just issue a refresh (default behavior). The data being returned with each container + * is the context information that may be needed to render certain partials, such as + * the contained sidebar for rendering widgets or what the nav menu args are for a menu. + */ + partialPlacementContexts[ partialId ] = _.map( partialsPlacements[ partialId ], function( placement ) { + return placement.context || {}; + } ); + } + } ); + + data.partials = JSON.stringify( partialPlacementContexts ); + data[ self.data.renderQueryVar ] = '1'; + + request = self._currentRequest = wp.ajax.send( null, { + data: data, + url: api.settings.url.self + } ); + + request.done( function( data ) { + + /** + * Announce the data returned from a request to render partials. + * + * The data is filtered on the server via customize_render_partials_response + * so plugins can inject data from the server to be utilized + * on the client via this event. Plugins may use this filter + * to communicate script and style dependencies that need to get + * injected into the page to support the rendered partials. + * This is similar to the 'saved' event. + */ + self.trigger( 'render-partials-response', data ); + + // Relay errors (warnings) captured during rendering and relay to console. + if ( data.errors && 'undefined' !== typeof console && console.warn ) { + _.each( data.errors, function( error ) { + console.warn( error ); + } ); + } + + /* + * Note that data is an array of items that correspond to the array of + * containers that were submitted in the request. So we zip up the + * array of containers with the array of contents for those containers, + * and send them into . + */ + _.each( self._pendingPartialRequests, function( pending, partialId ) { + var placementsContents; + if ( ! _.isArray( data.contents[ partialId ] ) ) { + pending.deferred.rejectWith( pending.partial, [ new Error( 'unrecognized_partial' ), partialsPlacements[ partialId ] ] ); + } else { + placementsContents = _.map( data.contents[ partialId ], function( content, i ) { + var partialPlacement = partialsPlacements[ partialId ][ i ]; + if ( partialPlacement ) { + partialPlacement.addedContent = content; + } else { + partialPlacement = new Placement( { + partial: pending.partial, + addedContent: content + } ); + } + return partialPlacement; + } ); + pending.deferred.resolveWith( pending.partial, [ placementsContents ] ); + } + } ); + self._pendingPartialRequests = {}; + } ); + + request.fail( function( data, statusText ) { + + /* + * Ignore failures caused by partial.currentRequest.abort() + * The pending deferreds will remain in self._pendingPartialRequests + * for re-use with the next request. + */ + if ( 'abort' === statusText ) { + return; + } + + _.each( self._pendingPartialRequests, function( pending, partialId ) { + pending.deferred.rejectWith( pending.partial, [ data, partialsPlacements[ partialId ] ] ); + } ); + self._pendingPartialRequests = {}; + } ); + }, + self.data.refreshBuffer + ); + + return partialRequest.deferred.promise(); + }; + + /** + * Add partials for any nav menu container elements in the document. + * + * This method may be called multiple times. Containers that already have been + * seen will be skipped. + * + * @since 4.5.0 + * + * @param {jQuery|HTMLElement} [rootElement] + * @param {object} [options] + * @param {boolean=true} [options.triggerRendered] + */ + self.addPartials = function( rootElement, options ) { + var containerElements; + if ( ! rootElement ) { + rootElement = document.documentElement; + } + rootElement = $( rootElement ); + options = _.extend( + { + triggerRendered: true + }, + options || {} + ); + + containerElements = rootElement.find( '[data-customize-partial-id]' ); + if ( rootElement.is( '[data-customize-partial-id]' ) ) { + containerElements = containerElements.add( rootElement ); + } + containerElements.each( function() { + var containerElement = $( this ), partial, id, Constructor, partialOptions, containerContext; + id = containerElement.data( 'customize-partial-id' ); + if ( ! id ) { + return; + } + containerContext = containerElement.data( 'customize-partial-placement-context' ) || {}; + + partial = self.partial( id ); + if ( ! partial ) { + partialOptions = containerElement.data( 'customize-partial-options' ) || {}; + partialOptions.constructingContainerContext = containerElement.data( 'customize-partial-placement-context' ) || {}; + Constructor = self.partialConstructor[ containerElement.data( 'customize-partial-type' ) ] || self.Partial; + partial = new Constructor( id, partialOptions ); + self.partial.add( partial.id, partial ); + } + + /* + * Only trigger renders on (nested) partials that have been not been + * handled yet. An example where this would apply is a nav menu + * embedded inside of a custom menu widget. When the widget's title + * is updated, the entire widget will re-render and then the event + * will be triggered for the nested nav menu to do any initialization. + */ + if ( options.triggerRendered && ! containerElement.data( 'customize-partial-content-rendered' ) ) { + + /** + * Announce when a partial's nested placement has been re-rendered. + */ + self.trigger( 'partial-content-rendered', new Placement( { + partial: partial, + context: containerContext, + container: containerElement + } ) ); + } + containerElement.data( 'customize-partial-content-rendered', true ); + } ); + }; + + api.bind( 'preview-ready', function() { + var handleSettingChange, watchSettingChange, unwatchSettingChange; + + // Polyfill for IE8 to support the document.head attribute. + if ( ! document.head ) { + document.head = $( 'head:first' )[0]; + } + + _.extend( self.data, _customizePartialRefreshExports ); + + // Create the partial JS models. + _.each( self.data.partials, function( data, id ) { + var Constructor, partial = self.partial( id ); + if ( ! partial ) { + Constructor = self.partialConstructor[ data.type ] || self.Partial; + partial = new Constructor( id, { params: data } ); + self.partial.add( id, partial ); + } else { + _.extend( partial.params, data ); + } + } ); + + /** + * Handle change to a setting. + * + * Note this is largely needed because adding a 'change' event handler to wp.customize + * will only include the changed setting object as an argument, not including the + * new value or the old value. + * + * @since 4.5.0 + * @this {wp.customize.Setting} + * + * @param {*|null} newValue New value, or null if the setting was just removed. + * @param {*|null} oldValue Old value, or null if the setting was just added. + */ + handleSettingChange = function( newValue, oldValue ) { + var setting = this; + self.partial.each( function( partial ) { + if ( partial.isRelatedSetting( setting, newValue, oldValue ) ) { + partial.refresh(); + } + } ); + }; + + /** + * Trigger the initial change for the added setting, and watch for changes. + * + * @since 4.5.0 + * @this {wp.customize.Values} + * + * @param {wp.customize.Setting} setting + */ + watchSettingChange = function( setting ) { + handleSettingChange.call( setting, setting(), null ); + setting.bind( handleSettingChange ); + }; + + /** + * Trigger the final change for the removed setting, and unwatch for changes. + * + * @since 4.5.0 + * @this {wp.customize.Values} + * + * @param {wp.customize.Setting} setting + */ + unwatchSettingChange = function( setting ) { + handleSettingChange.call( setting, null, setting() ); + setting.unbind( handleSettingChange ); + }; + + api.bind( 'add', watchSettingChange ); + api.bind( 'remove', unwatchSettingChange ); + api.each( function( setting ) { + setting.bind( handleSettingChange ); + } ); + + // Add (dynamic) initial partials that are declared via data-* attributes. + self.addPartials( document.documentElement, { + triggerRendered: false + } ); + + // Add new dynamic partials when the document changes. + if ( 'undefined' !== typeof MutationObserver ) { + self.mutationObserver = new MutationObserver( function( mutations ) { + _.each( mutations, function( mutation ) { + self.addPartials( $( mutation.target ) ); + } ); + } ); + self.mutationObserver.observe( document.documentElement, { + childList: true, + subtree: true + } ); + } + + /** + * Handle rendering of partials. + * + * @param {api.selectiveRefresh.Placement} placement + */ + api.selectiveRefresh.bind( 'partial-content-rendered', function( placement ) { + if ( placement.container ) { + self.addPartials( placement.container ); + } + } ); + + api.preview.bind( 'active', function() { + + // Make all partials ready. + self.partial.each( function( partial ) { + partial.deferred.ready.resolve(); + } ); + + // Make all partials added henceforth as ready upon add. + self.partial.bind( 'add', function( partial ) { + partial.deferred.ready.resolve(); + } ); + } ); + + } ); + + return self; +}( jQuery, wp.customize ) ); diff --git src/wp-includes/script-loader.php src/wp-includes/script-loader.php index 1a9d259..01be0f5 100644 --- src/wp-includes/script-loader.php +++ src/wp-includes/script-loader.php @@ -447,6 +447,7 @@ function wp_default_scripts( &$scripts ) { // Used for overriding the file types allowed in plupload. 'allowedFiles' => __( 'Allowed Files' ), ) ); + $scripts->add( 'customize-selective-refresh', "/wp-includes/js/customize-selective-refresh$suffix.js", array( 'jquery', 'wp-util', 'customize-preview' ), false, 1 ); $scripts->add( 'customize-widgets', "/wp-admin/js/customize-widgets$suffix.js", array( 'jquery', 'jquery-ui-sortable', 'jquery-ui-droppable', 'wp-backbone', 'customize-controls' ), false, 1 ); $scripts->add( 'customize-preview-widgets', "/wp-includes/js/customize-preview-widgets$suffix.js", array( 'jquery', 'wp-util', 'customize-preview' ), false, 1 ); diff --git tests/phpunit/tests/customize/manager.php tests/phpunit/tests/customize/manager.php index 0b86b4c..6f5789d 100644 --- tests/phpunit/tests/customize/manager.php +++ tests/phpunit/tests/customize/manager.php @@ -425,7 +425,7 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $data = json_decode( $json, true ); $this->assertNotEmpty( $data ); - $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices' ), array_keys( $data ) ); + $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices', 'selectiveRefreshEnabled' ), array_keys( $data ) ); $this->assertEquals( $autofocus, $data['autofocus'] ); $this->assertArrayHasKey( 'save', $data['nonce'] ); $this->assertArrayHasKey( 'preview', $data['nonce'] ); diff --git tests/phpunit/tests/customize/nav-menu-item-setting.php tests/phpunit/tests/customize/nav-menu-item-setting.php index 39ed42e..3431ef8 100644 --- tests/phpunit/tests/customize/nav-menu-item-setting.php +++ tests/phpunit/tests/customize/nav-menu-item-setting.php @@ -69,7 +69,6 @@ class Test_WP_Customize_Nav_Menu_Item_Setting extends WP_UnitTestCase { $setting = new WP_Customize_Nav_Menu_Item_Setting( $this->wp_customize, 'nav_menu_item[123]' ); $this->assertEquals( 'nav_menu_item', $setting->type ); - $this->assertEquals( 'postMessage', $setting->transport ); $this->assertEquals( 123, $setting->post_id ); $this->assertNull( $setting->previous_post_id ); $this->assertNull( $setting->update_status ); diff --git tests/phpunit/tests/customize/nav-menus.php tests/phpunit/tests/customize/nav-menus.php index 2969a2d..a65b428 100644 --- tests/phpunit/tests/customize/nav-menus.php +++ tests/phpunit/tests/customize/nav-menus.php @@ -353,11 +353,11 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { $expected = array( 'type' => 'nav_menu_item' ); $results = $menus->filter_dynamic_setting_args( $this->wp_customize, 'nav_menu_item[123]' ); - $this->assertEquals( $expected, $results ); + $this->assertEquals( $expected['type'], $results['type'] ); $expected = array( 'type' => 'nav_menu' ); $results = $menus->filter_dynamic_setting_args( $this->wp_customize, 'nav_menu[123]' ); - $this->assertEquals( $expected, $results ); + $this->assertEquals( $expected['type'], $results['type'] ); } /** @@ -532,13 +532,9 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { $menus = new WP_Customize_Nav_Menus( $this->wp_customize ); $menus->customize_preview_init(); - $this->assertEquals( 10, has_action( 'template_redirect', array( $menus, 'render_menu' ) ) ); $this->assertEquals( 10, has_action( 'wp_enqueue_scripts', array( $menus, 'customize_preview_enqueue_deps' ) ) ); - - if ( ! isset( $_REQUEST[ WP_Customize_Nav_Menus::RENDER_QUERY_VAR ] ) ) { - $this->assertEquals( 1000, has_filter( 'wp_nav_menu_args', array( $menus, 'filter_wp_nav_menu_args' ) ) ); - $this->assertEquals( 10, has_filter( 'wp_nav_menu', array( $menus, 'filter_wp_nav_menu' ) ) ); - } + $this->assertEquals( 1000, has_filter( 'wp_nav_menu_args', array( $menus, 'filter_wp_nav_menu_args' ) ) ); + $this->assertEquals( 10, has_filter( 'wp_nav_menu', array( $menus, 'filter_wp_nav_menu' ) ) ); } /** @@ -548,37 +544,25 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { */ function test_filter_wp_nav_menu_args() { do_action( 'customize_register', $this->wp_customize ); - $menus = new WP_Customize_Nav_Menus( $this->wp_customize ); + $menus = $this->wp_customize->nav_menus; $results = $menus->filter_wp_nav_menu_args( array( 'echo' => true, 'fallback_cb' => 'wp_page_menu', 'walker' => '', 'menu' => wp_create_nav_menu( 'Foo' ), + 'items_wrap' => '
    %3$s
', ) ); - $this->assertEquals( 1, $results['can_partial_refresh'] ); + $this->assertArrayHasKey( 'customize_preview_nav_menus_args', $results ); - $expected = array( - 'echo', - 'can_partial_refresh', - 'fallback_cb', - 'instance_number', - 'walker', - ); $results = $menus->filter_wp_nav_menu_args( array( 'echo' => false, 'fallback_cb' => 'wp_page_menu', 'walker' => new Walker_Nav_Menu(), + 'items_wrap' => '
    %3$s
', ) ); - $this->assertEqualSets( $expected, array_keys( $results ) ); + $this->assertArrayNotHasKey( 'customize_preview_nav_menus_args', $results ); $this->assertEquals( 'wp_page_menu', $results['fallback_cb'] ); - $this->assertEquals( 0, $results['can_partial_refresh'] ); - - $this->assertNotEmpty( $menus->preview_nav_menu_instance_args[ $results['instance_number'] ] ); - $preview_nav_menu_instance_args = $menus->preview_nav_menu_instance_args[ $results['instance_number'] ]; - $this->assertEquals( '', $preview_nav_menu_instance_args['fallback_cb'] ); - $this->assertEquals( '', $preview_nav_menu_instance_args['walker'] ); - $this->assertNotEmpty( $preview_nav_menu_instance_args['args_hash'] ); } /** @@ -595,19 +579,18 @@ class Test_WP_Customize_Nav_Menus extends WP_UnitTestCase { 'menu' => wp_create_nav_menu( 'Foo' ), 'fallback_cb' => 'wp_page_menu', 'walker' => '', + 'items_wrap' => '
    %3$s
', ) ); ob_start(); wp_nav_menu( $args ); $nav_menu_content = ob_get_clean(); - $object_args = json_decode( json_encode( $args ), false ); - $result = $menus->filter_wp_nav_menu( $nav_menu_content, $object_args ); - $expected = sprintf( - '