| @@ -220,16 +220,30 @@ | ||
| 220 | 220 | * `minWidth` produces a real min-width query instead of being skipped — |
| 221 | 221 | * animations already behaved this way, style rules did not. |
| 222 | 222 | */ |
| 223 | 223 | public static function breakpoint_media_query( $device ) { |
| 224 | - $device = (array) $device; | |
| 225 | - $min = isset( $device['min'] ) ? (int) $device['min'] : 0; | |
| 226 | - $max = isset( $device['max'] ) ? (int) $device['max'] : 0; | |
| 224 | + list( $min, $max ) = self::breakpoint_bounds( $device ); | |
| 227 | 225 | |
| 228 | 226 | if ( $min < 1 && $max < 1 ) { |
| 229 | 227 | return ''; |
| 230 | 228 | } |
| 231 | 229 | |
| 230 | + $condition = self::breakpoint_media_condition( $min, $max ); | |
| 231 | + return '' === $condition ? '' : '@media screen and ' . $condition; | |
| 232 | + } | |
| 233 | + | |
| 234 | + /** | |
| 235 | + * The [ min, max ] a device's media query actually covers. Mirror of the | |
| 236 | + * JS `effectiveBounds()`. | |
| 237 | + * | |
| 238 | + * @param array $device A device entry. | |
| 239 | + * @return int[] [ min, max ], 0 meaning unbounded. | |
| 240 | + */ | |
| 241 | + public static function breakpoint_bounds( $device ) { | |
| 242 | + $device = (array) $device; | |
| 243 | + $min = isset( $device['min'] ) ? (int) $device['min'] : 0; | |
| 244 | + $max = isset( $device['max'] ) ? (int) $device['max'] : 0; | |
| 245 | + | |
| 232 | 246 | /* |
| 233 | 247 | * Strict mode bounds a max-width breakpoint from below with the next |
| 234 | 248 | * narrower breakpoint, turning overlapping envelopes into exclusive |
| 235 | 249 | * bands. A breakpoint that already declares its own min is left alone — |
| @@ -241,10 +255,9 @@ | ||
| 241 | 255 | $min = $narrower + 1; |
| 242 | 256 | } |
| 243 | 257 | } |
| 244 | 258 | |
| 245 | - $condition = self::breakpoint_media_condition( $min, $max ); | |
| 246 | - return '' === $condition ? '' : '@media screen and ' . $condition; | |
| 259 | + return [ $min, $max ]; | |
| 247 | 260 | } |
| 248 | 261 | |
| 249 | 262 | /** The largest max-width bound narrower than $max, or 0 if none. */ |
| 250 | 263 | private static function next_narrower_max( $max ) { |
| @@ -415,43 +428,135 @@ | ||
| 415 | 428 | public static function get_array_value( $array, $key, $default ) { |
| 416 | 429 | return ( isset( $array[ $key ] ) && ! empty( $array[ $key ] ) ) ? $array[ $key ] : $default; |
| 417 | 430 | } |
| 418 | 431 | |
| 419 | - public static function get_responsive_value( $attribute, $attribute_object_key, $device, $attribute_default_value = [] ) { | |
| 420 | - // Closure to "clean" a value (similar to your JS clean() helper) | |
| 421 | - $clean = function( $value ) { | |
| 422 | - return self::has_value( $value ) ? $value : null; | |
| 423 | - }; | |
| 432 | + /** | |
| 433 | + * Whether a stored responsive value is set — the mirror of the editor's | |
| 434 | + * hasValue(). 0 and '0' are set; null, '' and blank strings are unset (they | |
| 435 | + * inherit); arrays/objects are set when non-empty. Unlike has_value(), which | |
| 436 | + * many unrelated callers rely on, this never treats 0 as missing. | |
| 437 | + * | |
| 438 | + * @param mixed $value Stored value. | |
| 439 | + * @return bool | |
| 440 | + */ | |
| 441 | + public static function has_responsive_value( $value ) { | |
| 442 | + if ( null === $value ) { | |
| 443 | + return false; | |
| 444 | + } | |
| 445 | + if ( is_string( $value ) ) { | |
| 446 | + return '' !== trim( $value ); | |
| 447 | + } | |
| 448 | + if ( is_array( $value ) ) { | |
| 449 | + return ! empty( $value ); | |
| 450 | + } | |
| 451 | + if ( is_object( $value ) ) { | |
| 452 | + return ! empty( get_object_vars( $value ) ); | |
| 453 | + } | |
| 454 | + return true; | |
| 455 | + } | |
| 424 | 456 | |
| 425 | - // Desktop value (fallback to default, or false if nothing found) | |
| 426 | - $desktop_value = | |
| 427 | - $clean( $attribute[ $attribute_object_key ] ?? null ) ?? | |
| 428 | - $clean( $attribute_default_value[ $attribute_object_key ] ?? null ) ?? | |
| 429 | - false; | |
| 457 | + /** | |
| 458 | + * The devices a device inherits from, nearest first, ending with Desktop ('') | |
| 459 | + * — the mirror of the editor's getDeviceAncestors(). | |
| 460 | + * | |
| 461 | + * A wider device is an ancestor only when its range contains the device's own | |
| 462 | + * width, i.e. exactly when the frontend cascade applies its rule there, so a | |
| 463 | + * min-only (≥1400) or banded (900–1200) breakpoint is never inherited by | |
| 464 | + * Tablet. With the built-in devices this is Tablet ← Desktop, Mobile ← Tablet. | |
| 465 | + * Desktop has no ancestors; an unknown suffix (e.g. the phantom probe) | |
| 466 | + * inherits Desktop only. | |
| 467 | + * | |
| 468 | + * @param string $device Device suffix ('' | 'Desktop' | 'Tablet' | 'Bp…'). | |
| 469 | + * @return string[] Ancestor suffixes, nearest first. | |
| 470 | + */ | |
| 471 | + public static function get_responsive_ancestors( $device ) { | |
| 472 | + $target = 'Desktop' === $device ? '' : (string) $device; | |
| 473 | + if ( '' === $target ) { | |
| 474 | + return []; | |
| 475 | + } | |
| 476 | + $devices = array_values( self::get_responsive_devices() ); | |
| 477 | + $index = array_search( $target, array_column( $devices, 'suffix' ), true ); | |
| 478 | + if ( false === $index ) { | |
| 479 | + return [ '' ]; | |
| 480 | + } | |
| 481 | + $ancestors = []; | |
| 482 | + for ( $i = $index - 1; $i >= 0; $i-- ) { | |
| 483 | + if ( empty( $devices[ $i ]['min'] ) && empty( $devices[ $i ]['max'] ) ) { | |
| 484 | + continue; // the base is always last | |
| 485 | + } | |
| 486 | + if ( self::breakpoint_contains( $devices[ $i ], $devices[ $index ] ) ) { | |
| 487 | + $ancestors[] = $devices[ $i ]['suffix']; | |
| 488 | + } | |
| 489 | + } | |
| 490 | + $ancestors[] = ''; | |
| 491 | + return $ancestors; | |
| 492 | + } | |
| 430 | 493 | |
| 431 | - // Tablet value (fallback to desktop if nothing found) | |
| 432 | - $tablet_key = $attribute_object_key . 'Tablet'; | |
| 433 | - $tablet_value = | |
| 434 | - $clean( $attribute[ $tablet_key ] ?? null ) ?? | |
| 435 | - $clean( $attribute_default_value[ $tablet_key ] ?? null ) ?? | |
| 436 | - $desktop_value; | |
| 494 | + /** | |
| 495 | + * Whether breakpoint $outer's range contains breakpoint $inner, judged at | |
| 496 | + * $inner's own width (its max, else its min) — i.e. whether $outer's rule | |
| 497 | + * still applies where $inner starts. The base device contains everything. | |
| 498 | + * Shared by inheritance (get_responsive_ancestors) and CSS dedupe. | |
| 499 | + * | |
| 500 | + * @param array $outer Breakpoint with `min` / `max` bounds. | |
| 501 | + * @param array $inner Breakpoint with `min` / `max` bounds. | |
| 502 | + * @return bool | |
| 503 | + */ | |
| 504 | + /** | |
| 505 | + * Run a per-device CSS builder for a custom-breakpoint (or phantom probe) | |
| 506 | + * suffix. Block builders read `$attributes[ 'x' . $device ]` directly, and | |
| 507 | + * attribute defaults only declare the built-in suffixes, so a custom suffix | |
| 508 | + * key is missing — which is exactly "unset" for the caller | |
| 509 | + * (custom_device_map() subtracts the phantom baseline). Only those | |
| 510 | + * missing-key warnings are silenced, and only while the builder runs. | |
| 511 | + * | |
| 512 | + * @param callable $builder `( $device ) => styles`. | |
| 513 | + * @param string $device Device suffix. | |
| 514 | + * @return array Styles. | |
| 515 | + */ | |
| 516 | + public static function call_device_builder( $builder, $device ) { | |
| 517 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler | |
| 518 | + set_error_handler( | |
| 519 | + function ( $errno, $errstr ) { | |
| 520 | + return (bool) preg_match( '/^Undefined (array key|index|offset)/', $errstr ); | |
| 521 | + }, | |
| 522 | + E_WARNING | E_NOTICE | |
| 523 | + ); | |
| 524 | + try { | |
| 525 | + return (array) call_user_func( $builder, $device ); | |
| 526 | + } finally { | |
| 527 | + restore_error_handler(); | |
| 528 | + } | |
| 529 | + } | |
| 437 | 530 | |
| 438 | - // Mobile value (fallback to tablet if nothing found) | |
| 439 | - $mobile_key = $attribute_object_key . 'Mobile'; | |
| 440 | - $mobile_value = | |
| 441 | - $clean( $attribute[ $mobile_key ] ?? null ) ?? | |
| 442 | - $clean( $attribute_default_value[ $mobile_key ] ?? null ) ?? | |
| 443 | - $tablet_value; | |
| 531 | + public static function breakpoint_contains( $outer, $inner ) { | |
| 532 | + $o_min = isset( $outer['min'] ) ? (int) $outer['min'] : 0; | |
| 533 | + $o_max = isset( $outer['max'] ) ? (int) $outer['max'] : 0; | |
| 534 | + $i_min = isset( $inner['min'] ) ? (int) $inner['min'] : 0; | |
| 535 | + $i_max = isset( $inner['max'] ) ? (int) $inner['max'] : 0; | |
| 536 | + $width = $i_max ? $i_max : $i_min; | |
| 537 | + return ( ! $o_min || $width >= $o_min ) && ( ! $o_max || $width <= $o_max ); | |
| 538 | + } | |
| 444 | 539 | |
| 445 | - // Return based on device | |
| 446 | - switch ( $device ) { | |
| 447 | - case 'Mobile': | |
| 448 | - return $mobile_value; | |
| 449 | - case 'Tablet': | |
| 450 | - return $tablet_value; | |
| 451 | - default: | |
| 452 | - return $desktop_value; | |
| 540 | + /** | |
| 541 | + * A device's value for a key: its own, else the nearest containing wider | |
| 542 | + * device's (see get_responsive_ancestors()), else Desktop's — the mirror of | |
| 543 | + * the editor's getResponsiveValue(). Stored values win over declared | |
| 544 | + * defaults per device. Returns false when nothing is set anywhere. | |
| 545 | + */ | |
| 546 | + public static function get_responsive_value( $attribute, $attribute_object_key, $device, $attribute_default_value = [] ) { | |
| 547 | + $target = 'Desktop' === $device ? '' : (string) $device; | |
| 548 | + $suffixes = array_merge( [ $target ], self::get_responsive_ancestors( $target ) ); | |
| 549 | + foreach ( $suffixes as $suffix ) { | |
| 550 | + $key = $attribute_object_key . $suffix; | |
| 551 | + if ( isset( $attribute[ $key ] ) && self::has_responsive_value( $attribute[ $key ] ) ) { | |
| 552 | + return $attribute[ $key ]; | |
| 553 | + } | |
| 554 | + if ( isset( $attribute_default_value[ $key ] ) && self::has_responsive_value( $attribute_default_value[ $key ] ) ) { | |
| 555 | + return $attribute_default_value[ $key ]; | |
| 556 | + } | |
| 453 | 557 | } |
| 558 | + return false; | |
| 454 | 559 | } |
| 455 | 560 | |
| 456 | 561 | public static function is_gutenberg_editor() { |
| 457 | 562 | global $pagenow; |