diff --git a/lib/block-supports/duotone.php b/lib/block-supports/duotone.php index 5869d521b2d380..5d507ea051ee05 100644 --- a/lib/block-supports/duotone.php +++ b/lib/block-supports/duotone.php @@ -251,31 +251,17 @@ function gutenberg_register_duotone_support( $block_type ) { } /** - * Render out the duotone stylesheet and SVG. + * Renders the duotone filter SVG and returns the CSS filter property to + * reference the rendered SVG. * - * @param string $block_content Rendered block content. - * @param array $block Block object. - * @return string Filtered block content. + * @param array $preset Duotone preset value as seen in theme.json. + * + * @return string Duotone CSS filter property. */ -function gutenberg_render_duotone_support( $block_content, $block ) { - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); - - $duotone_support = false; - if ( $block_type && property_exists( $block_type, 'supports' ) ) { - $duotone_support = _wp_array_get( $block_type->supports, array( 'color', '__experimentalDuotone' ), false ); - } - - $has_duotone_attribute = isset( $block['attrs']['style']['color']['duotone'] ); - - if ( - ! $duotone_support || - ! $has_duotone_attribute - ) { - return $block_content; - } - - $duotone_colors = $block['attrs']['style']['color']['duotone']; - +function gutenberg_render_duotone_filter_preset( $preset ) { + $duotone_id = $preset['slug']; + $duotone_colors = $preset['colors']; + $filter_id = 'wp-duotone-' . $duotone_id; $duotone_values = array( 'r' => array(), 'g' => array(), @@ -289,29 +275,12 @@ function gutenberg_render_duotone_support( $block_content, $block ) { $duotone_values['b'][] = $color['b'] / 255; } - $duotone_id = 'wp-duotone-filter-' . uniqid(); - - $selectors = explode( ',', $duotone_support ); - $selectors_scoped = array_map( - function ( $selector ) use ( $duotone_id ) { - return '.' . $duotone_id . ' ' . trim( $selector ); - }, - $selectors - ); - $selectors_group = implode( ', ', $selectors_scoped ); - ob_start(); ?> - - - + - values=".299 .587 .114 0 0 - .299 .587 .114 0 0 - .299 .587 .114 0 0 - 0 0 0 1 0" - + values=" + .299 .587 .114 0 0 + .299 .587 .114 0 0 + .299 .587 .114 0 0 + 0 0 0 1 0 + " /> @@ -341,25 +310,84 @@ function ( $selector ) use ( $duotone_id ) { <', $svg ); + $svg = trim( $svg ); + } add_action( - // Ideally we should use wp_head, but SVG defs can't be put in there. - 'wp_footer', - function () use ( $duotone ) { - echo $duotone; + // Safari doesn't render SVG filters defined in data URIs, + // and SVG filters won't render in the head of a document, + // so the next best place to put the SVG is in the footer. + is_admin() ? 'admin_footer' : 'wp_footer', + function () use ( $svg ) { + echo $svg; } ); - return $content; + return "url('#" . $filter_id . "')"; +} + +/** + * Render out the duotone stylesheet and SVG. + * + * @param string $block_content Rendered block content. + * @param array $block Block object. + * @return string Filtered block content. + */ +function gutenberg_render_duotone_support( $block_content, $block ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + + $duotone_support = false; + if ( $block_type && property_exists( $block_type, 'supports' ) ) { + $duotone_support = _wp_array_get( $block_type->supports, array( 'color', '__experimentalDuotone' ), false ); + } + + $has_duotone_attribute = isset( $block['attrs']['style']['color']['duotone'] ); + + if ( + ! $duotone_support || + ! $has_duotone_attribute + ) { + return $block_content; + } + + $filter_preset = array( + 'slug' => uniqid(), + 'colors' => $block['attrs']['style']['color']['duotone'], + ); + $filter_property = gutenberg_render_duotone_filter_preset( $filter_preset ); + $filter_id = 'wp-duotone-' . $filter_preset['slug']; + + $scope = '.' . $filter_id; + $selectors = explode( ',', $duotone_support ); + $scoped = array(); + foreach ( $selectors as $sel ) { + $scoped[] = $scope . ' ' . trim( $sel ); + } + $selector = implode( ', ', $scoped ); + + // !important is needed because these styles render before global styles, + // and they should be overriding the duotone filters set by global styles. + $filter_style = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG + ? $selector . " {\n\tfilter: " . $filter_property . " !important;\n}\n" + : $selector . '{filter:' . $filter_property . ' !important;}'; + + wp_register_style( $filter_id, false, array(), true, true ); + wp_add_inline_style( $filter_id, $filter_style ); + wp_enqueue_style( $filter_id ); + + // Like the layout hook, this assumes the hook only applies to blocks with a single wrapper. + return preg_replace( + '/' . preg_quote( 'class="', '/' ) . '/', + 'class="' . $filter_id . ' ', + $block_content, + 1 + ); } // Register the block support. diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 964d4d7f527cf0..3923516efffe9f 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -60,6 +60,9 @@ class WP_Theme_JSON_Gutenberg { 'gradient' => null, 'text' => null, ), + 'filter' => array( + 'duotone' => null, + ), 'spacing' => array( 'margin' => null, 'padding' => null, @@ -142,6 +145,9 @@ class WP_Theme_JSON_Gutenberg { * * - value_key => the key that represents the value * + * - value_func => optionally, instead of value_key, a function to generate + * the value that takes a preset as an argument + * * - css_var_infix => infix to use in generating the CSS Custom Property. Example: * --wp--preset----: * @@ -184,6 +190,12 @@ class WP_Theme_JSON_Gutenberg { ), ), ), + array( + 'path' => array( 'color', 'duotone' ), + 'value_func' => 'gutenberg_render_duotone_filter_preset', + 'css_var_infix' => 'duotone', + 'classes' => array(), + ), array( 'path' => array( 'typography', 'fontSizes' ), 'value_key' => 'size', @@ -242,6 +254,16 @@ class WP_Theme_JSON_Gutenberg { 'text-transform' => array( 'typography', 'textTransform' ), ); + /** + * Metadata for style properties that need to use the duotone selector. + * + * Each element is a direct mapping from the CSS property name to the + * path to the value in theme.json & block attributes. + */ + const DUOTONE_PROPERTIES_METADATA = array( + 'filter' => array( 'filter', 'duotone' ), + ); + const ELEMENTS = array( 'link' => 'a', 'h1' => 'h1', @@ -278,8 +300,8 @@ public function __construct( $theme_json = array(), $origin = 'theme' ) { // Internally, presets are keyed by origin. $nodes = self::get_setting_nodes( $this->theme_json ); foreach ( $nodes as $node ) { - foreach ( self::PRESETS_METADATA as $preset ) { - $path = array_merge( $node['path'], $preset['path'] ); + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $path = array_merge( $node['path'], $preset_metadata['path'] ); $preset = _wp_array_get( $this->theme_json, $path, null ); if ( null !== $preset ) { gutenberg_experimental_set( $this->theme_json, $path, array( $origin => $preset ) ); @@ -359,9 +381,13 @@ private static function sanitize( $input, $valid_block_names, $valid_element_nam * }, * 'core/heading': { * 'selector': 'h1' - * } + * }, * 'core/group': { * 'selector': '.wp-block-group' + * }, + * 'core/cover': { + * 'selector': '.wp-block-cover', + * 'duotone': '> .wp-block-cover__image-background, > .wp-block-cover__video-background' * } * } * @@ -386,6 +412,13 @@ private static function get_blocks_metadata() { self::$blocks_metadata[ $block_name ]['selector'] = '.wp-block-' . str_replace( '/', '-', str_replace( 'core/', '', $block_name ) ); } + if ( + isset( $block_type->supports['color']['__experimentalDuotone'] ) && + is_string( $block_type->supports['color']['__experimentalDuotone'] ) + ) { + self::$blocks_metadata[ $block_name ]['duotone'] = $block_type->supports['color']['__experimentalDuotone']; + } + // Assign defaults, then overwrite those that the block sets by itself. // If the block selector is compounded, will append the element to each // individual block selector. @@ -539,16 +572,17 @@ private static function get_property_value( $styles, $path ) { * ``` * * @param array $styles Styles to process. + * @param array $properties Properties metadata. * * @return array Returns the modified $declarations. */ - private static function compute_style_properties( $styles ) { + private static function compute_style_properties( $styles, $properties = self::PROPERTIES_METADATA ) { $declarations = array(); if ( empty( $styles ) ) { return $declarations; } - foreach ( self::PROPERTIES_METADATA as $css_property => $value_path ) { + foreach ( $properties as $css_property => $value_path ) { $value = self::get_property_value( $styles, $value_path ); // Skip if empty and not "0" or value represents array of longhand values. @@ -589,28 +623,120 @@ private static function append_to_selector( $selector, $to_append ) { } /** - * Function that given an array of presets keyed by origin - * and the value key of the preset returns an array where each key is - * the a preset slug and each value is the preset value. + * Function that scopes a selector with another one. This works a bit like + * SCSS nesting except the `&` operator isn't supported. + * + * + * $scope = '.a, .b .c'; + * $selector = '> .x, .y'; + * $merged = scope_selector( $scope, $selector ); + * // $merged is '.a > .x, .a .y, .b .c > .x, .b .c .y' + * * - * @param array $preset_per_origin Array of presets keyed by origin. - * @param string $value_key The property of the preset that contains its value. - * @param array $origins List of origins to process. + * @param string $scope Selector to scope to. + * @param string $selector Original selector. + * + * @return string Scoped selector. + */ + private static function scope_selector( $scope, $selector ) { + $scopes = explode( ',', $scope ); + $selectors = explode( ',', $selector ); + + $selectors_scoped = array(); + foreach ( $scopes as $outer ) { + foreach ( $selectors as $inner ) { + $selectors_scoped[] = trim( $outer ) . ' ' . trim( $inner ); + } + } + + return implode( ', ', $selectors_scoped ); + } + + /** + * Gets preset values keyed by slugs based on settings and metadata. + * + * + * $settings = array( + * 'typography' => array( + * 'fontFamilies' => array( + * array( + * 'slug' => 'sansSerif', + * 'fontFamily' => '"Helvetica Neue", sans-serif', + * ), + * array( + * 'slug' => 'serif', + * 'colors' => 'Georgia, serif', + * ) + * ), + * ), + * ); + * $meta = array( + * 'path' => array( 'typography', 'fontFamilies' ), + * 'value_key' => 'fontFamily', + * ); + * $values_by_slug = get_settings_values_by_slug(); + * // $values_by_slug === array( + * // 'sans-serif' => '"Helvetica Neue", sans-serif', + * // 'serif' => 'Georgia, serif', + * // ); + * + * + * @param array $settings Settings to process. + * @param array $preset_metadata One of the PRESETS_METADATA values. + * @param array $origins List of origins to process. * * @return array Array of presets where each key is a slug and each value is the preset value. */ - private static function get_merged_preset_by_slug( $preset_per_origin, $value_key, $origins ) { + private static function get_settings_values_by_slug( $settings, $preset_metadata, $origins ) { + $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() ); + $result = array(); foreach ( $origins as $origin ) { if ( ! isset( $preset_per_origin[ $origin ] ) ) { continue; } foreach ( $preset_per_origin[ $origin ] as $preset ) { - // We don't want to use kebabCase here, - // see https://github.com/WordPress/gutenberg/issues/32347 - // However, we need to make sure the generated class or css variable - // doesn't contain spaces. - $result[ preg_replace( '/\s+/', '-', $preset['slug'] ) ] = $preset[ $value_key ]; + $slug = gutenberg_experimental_to_kebab_case( $preset['slug'] ); + + $value = ''; + if ( isset( $preset_metadata['value_key'] ) ) { + $value_key = $preset_metadata['value_key']; + $value = $preset[ $value_key ]; + } elseif ( is_callable( $preset_metadata['value_func'] ) ) { + $value_func = $preset_metadata['value_func']; + $value = call_user_func( $value_func, $preset ); + } else { + // If we don't have a value, then don't add it to the result. + continue; + } + + $result[ $slug ] = $value; + } + } + return $result; + } + + /** + * Similar to get_settings_values_by_slug, but doesn't compute the value. + * + * @param array $settings Settings to process. + * @param array $preset_metadata One of the PRESETS_METADATA values. + * + * @return array Array of presets where the key and value are both the slug. + */ + private static function get_settings_slugs( $settings, $preset_metadata ) { + $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() ); + + $result = array(); + foreach ( self::VALID_ORIGINS as $origin ) { + if ( ! isset( $preset_per_origin[ $origin ] ) ) { + continue; + } + foreach ( $preset_per_origin[ $origin ] as $preset ) { + $slug = gutenberg_experimental_to_kebab_case( $preset['slug'] ); + + // Use the array as a set so we don't get duplicates. + $result[ $slug ] = $slug; } } return $result; @@ -634,17 +760,16 @@ private static function compute_preset_classes( $settings, $selector, $origins ) } $stylesheet = ''; - foreach ( self::PRESETS_METADATA as $preset ) { - $preset_per_origin = _wp_array_get( $settings, $preset['path'], array() ); - $preset_by_slug = self::get_merged_preset_by_slug( $preset_per_origin, $preset['value_key'], $origins ); - foreach ( $preset['classes'] as $class ) { - foreach ( $preset_by_slug as $slug => $value ) { + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $slugs = self::get_settings_slugs( $settings, $preset_metadata, $origins ); + foreach ( $preset_metadata['classes'] as $class ) { + foreach ( $slugs as $slug ) { $stylesheet .= self::to_ruleset( - self::append_to_selector( $selector, '.has-' . gutenberg_experimental_to_kebab_case( $slug ) . '-' . $class['class_suffix'] ), + self::append_to_selector( $selector, '.has-' . $slug . '-' . $class['class_suffix'] ), array( array( 'name' => $class['property_name'], - 'value' => 'var(--wp--preset--' . $preset['css_var_infix'] . '--' . gutenberg_experimental_to_kebab_case( $slug ) . ') !important', + 'value' => 'var(--wp--preset--' . $preset_metadata['css_var_infix'] . '--' . $slug . ') !important', ), ) ); @@ -674,12 +799,11 @@ private static function compute_preset_classes( $settings, $selector, $origins ) */ private static function compute_preset_vars( $settings, $origins ) { $declarations = array(); - foreach ( self::PRESETS_METADATA as $preset ) { - $preset_per_origin = _wp_array_get( $settings, $preset['path'], array() ); - $preset_by_slug = self::get_merged_preset_by_slug( $preset_per_origin, $preset['value_key'], $origins ); - foreach ( $preset_by_slug as $slug => $value ) { + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $values_by_slug = self::get_settings_values_by_slug( $settings, $preset_metadata, $origins ); + foreach ( $values_by_slug as $slug => $value ) { $declarations[] = array( - 'name' => '--wp--preset--' . $preset['css_var_infix'] . '--' . gutenberg_experimental_to_kebab_case( $slug ), + 'name' => '--wp--preset--' . $preset_metadata['css_var_infix'] . '--' . $slug, 'value' => $value, ); } @@ -833,6 +957,12 @@ private function get_block_classes( $style_nodes ) { $block_rules .= '.wp-site-blocks > * + * { margin-top: var( --wp--style--block-gap ); margin-bottom: 0; }'; } } + + if ( isset( $metadata['duotone'] ) ) { + $selector = self::scope_selector( $metadata['selector'], $metadata['duotone'] ); + $declarations = self::compute_style_properties( $node, self::DUOTONE_PROPERTIES_METADATA ); + $block_rules .= self::to_ruleset( $selector, $declarations ); + } } return $block_rules; @@ -959,11 +1089,13 @@ public function get_template_parts() { * [ * [ * 'path' => [ 'path', 'to', 'some', 'node' ], - * 'selector' => 'CSS selector for some node' + * 'selector' => 'CSS selector for some node', + * 'duotone' => 'CSS selector for duotone for some node' * ], * [ * 'path' => ['path', 'to', 'other', 'node' ], - * 'selector' => 'CSS selector for other node' + * 'selector' => 'CSS selector for other node', + * 'duotone' => null * ], * ] * @@ -1004,9 +1136,15 @@ private static function get_style_nodes( $theme_json, $selectors = array() ) { $selector = $selectors[ $name ]['selector']; } + $duotone_selector = null; + if ( isset( $selectors[ $name ]['duotone'] ) ) { + $duotone_selector = $selectors[ $name ]['duotone']; + } + $nodes[] = array( 'path' => array( 'styles', 'blocks', $name ), 'selector' => $selector, + 'duotone' => $duotone_selector, ); if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { @@ -1117,8 +1255,8 @@ public function merge( $incoming ) { // In those cases, we want to replace the existing with the incoming value, if it exists. $to_replace = array(); $to_replace[] = array( 'spacing', 'units' ); - $to_replace[] = array( 'color', 'duotone' ); foreach ( self::VALID_ORIGINS as $origin ) { + $to_replace[] = array( 'color', 'duotone', $origin ); $to_replace[] = array( 'color', 'palette', $origin ); $to_replace[] = array( 'color', 'gradients', $origin ); $to_replace[] = array( 'typography', 'fontSizes', $origin ); diff --git a/packages/block-editor/src/hooks/duotone.js b/packages/block-editor/src/hooks/duotone.js index b7074dc44f9a91..031914a8a88655 100644 --- a/packages/block-editor/src/hooks/duotone.js +++ b/packages/block-editor/src/hooks/duotone.js @@ -215,6 +215,37 @@ const withDuotoneControls = createHigherOrderComponent( 'withDuotoneControls' ); +/** + * Function that scopes a selector with another one. This works a bit like + * SCSS nesting except the `&` operator isn't supported. + * + * @example + * ```js + * const scope = '.a, .b .c'; + * const selector = '> .x, .y'; + * const merged = scopeSelector( scope, selector ); + * // merged is '.a > .x, .a .y, .b .c > .x, .b .c .y' + * ``` + * + * @param {string} scope Selector to scope to. + * @param {string} selector Original selector. + * + * @return {string} Scoped selector. + */ +function scopeSelector( scope, selector ) { + const scopes = scope.split( ',' ); + const selectors = selector.split( ',' ); + + const selectorsScoped = []; + scopes.forEach( ( outer ) => { + selectors.forEach( ( inner ) => { + selectorsScoped.push( `${ outer.trim() } ${ inner.trim() }` ); + } ); + } ); + + return selectorsScoped.join( ', ' ); +} + /** * Override the default block element to include duotone styles. * @@ -234,13 +265,15 @@ const withDuotoneStyles = createHigherOrderComponent( return ; } - const id = `wp-duotone-filter-${ useInstanceId( BlockListBlock ) }`; + const id = `wp-duotone-${ useInstanceId( BlockListBlock ) }`; - const selectors = duotoneSupport.split( ',' ); - const selectorsScoped = selectors.map( - ( selector ) => `.${ id } ${ selector.trim() }` + // Extra .editor-styles-wrapper specificity is needed in the editor + // since we're not using inline styles to apply the filter. We need to + // override duotone applied by global styles and theme.json. + const selectorsGroup = scopeSelector( + `.editor-styles-wrapper .${ id }`, + duotoneSupport ); - const selectorsGroup = selectorsScoped.join( ', ' ); const className = classnames( props?.className, id ); diff --git a/packages/blocks/src/api/constants.js b/packages/blocks/src/api/constants.js index 2ca489dd609f51..674caea6af4c99 100644 --- a/packages/blocks/src/api/constants.js +++ b/packages/blocks/src/api/constants.js @@ -129,6 +129,7 @@ export const __EXPERIMENTAL_ELEMENTS = { }; export const __EXPERIMENTAL_PATHS_WITH_MERGE = { + 'color.duotone': true, 'color.gradients': true, 'color.palette': true, 'typography.fontFamilies': true, diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 1ca9d90d6e78f0..d5f63cb7723faa 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -824,7 +824,9 @@ public function test_merge_incoming_data_empty_presets() { 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( 'color' => array( - 'duotone' => array(), + 'duotone' => array( + 'theme' => array(), + ), 'gradients' => array( 'theme' => array(), ), @@ -912,9 +914,11 @@ public function test_merge_incoming_data_null_presets() { 'color' => array( 'custom' => false, 'duotone' => array( - array( - 'slug' => 'value', - 'colors' => array( 'red', 'green' ), + 'theme' => array( + array( + 'slug' => 'value', + 'colors' => array( 'red', 'green' ), + ), ), ), 'gradients' => array(