Issue #3535453: Update the image component for responsive images and add...

Issue #3535453: Update the image component for responsive images and add example card components showing usage

Closes #3535453

Merge request reports

Loading
+45 −0
Changes for components/image/image.component.yml: 45 added lines, 0 removed lines.
Original line number Diff line number Diff line
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Image
# ℹ️ This means the target audience is other SDC developers, not XB. Which means there's no need for the additional semantics XB requires.
noUi: true
props:
  type: object
  required:
    - src
  properties:
    # ⚠️ This looks very similar to `json-schema-definitions://experience_builder.module/image`, but with 2 additional object properties. That's because this SDC is meant to be used by other SDCs, so putting everything under a single top-level SDC prop would yield a poor Developer Experience. Hence this intentionally does NOT use `json-schema-definitions://experience_builder.module/image`.
    src:
      title: Image URL
      type: string
      # ⚠️ Note this does NOT specify `$ref: json-schema-definitions://experience_builder.module/image-uri`, see comment above.
      examples:
        - https://placehold.co/600x400
    alt:
      title: Alternative text
      type: string
      examples:
        - Placeholder text
    width:
      title: Image width
      type: integer
      examples:
        - 600
    height:
      title: Image height
      type: integer
      examples:
        - 400
    sizes:
      title: Sizes
      type: string
      examples:
        - auto 100vw
    loading:
      title: Loading
      type: string
      default: lazy
      enum:
        - lazy
        - eager
      examples:
        - lazy
+5 −0
Changes for components/image/image.css: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
.image {
  max-width: 100%;
  height: auto;
  display: block;
}
+22 −0
Changes for components/image/image.twig: 22 added lines, 0 removed lines.
Original line number Diff line number Diff line
{% set width = width ?? src|getWidth %}
{% set height = height ?? src|getHeight %}
{% set srcset = src|toSrcSet(width) %}
<img
   class="{{ class ?? 'image' }}"
   src="{{ src }}"
   {% if srcset and srcset is not empty %}
     srcset="{{ srcset }}"
     sizes="{{ sizes ?? 'auto 100vw' }}"
   {% endif %}
   {% if alt %}
     alt="{{ alt }}"
   {% endif %}
   {% if width %}
     width="{{ width }}"
   {% endif %}
   {% if height %}
     height="{{ height }}"
   {% endif %}
   loading="{{ loading ?? 'lazy' }}"
   {{ attributes }}
/>
+105 −16
Changes for src/Extension/XbTwigExtension.php: 105 added lines, 16 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,11 @@ declare(strict_types=1);

namespace Drupal\experience_builder\Extension;

use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Image\ImageFactory;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\experience_builder\Entity\ParametrizedImageStyle;
use Drupal\experience_builder\Plugin\Field\FieldTypeOverride\ImageItemOverride;
use Drupal\experience_builder\Routing\ParametrizedImageStyleConverter;
use Twig\Extension\AbstractExtension;
@@ -18,6 +23,22 @@ use Twig\TwigFilter;
 */
final class XbTwigExtension extends AbstractExtension {

  /**
   * Constructs a new XbTwigExtension object.
   *
   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager
   *   The stream wrapper manager service.
   * @param \Drupal\Core\Image\ImageFactory $imageFactory
   *   The image factory service.
   * @param \Drupal\Core\File\FileUrlGeneratorInterface $fileUrlGenerator
   *   The file URL generator.
   */
  public function __construct(
    private readonly StreamWrapperManagerInterface $streamWrapperManager,
    private readonly ImageFactory $imageFactory,
    private readonly FileUrlGeneratorInterface $fileUrlGenerator,
  ) {}

  /**
   * {@inheritdoc}
   */
@@ -36,45 +57,113 @@ final class XbTwigExtension extends AbstractExtension {
        'toSrcSet',
        [$this, 'toSrcSet'],
      ),
      new TwigFilter(
        'getWidth',
        [$this, 'getWidth'],
      ),
      new TwigFilter(
        'getHeight',
        [$this, 'getHeight'],
      ),
    ];
  }

  /**
   * Twig filter to convert `alternateWidths` query parameter to `srcset`.
   * Filter to generate `srcset` from URLs with an `alternateWidths` parameter, or a local file.
   *
   * @param string $src
   *   An img.src attribute.
   * @param int $intrinsicImageWidth
   * @param int|null $intrinsicImageWidth
   *   The intrinsic width of the image in $src.
   *
   * @return null|string
   *   A `srcset` string, or NULL if none could be generated.
   */
  public static function toSrcSet(string $src, int $intrinsicImageWidth): ?string {
    $parts = parse_url($src);
    if (empty($parts['query'])) {
      return NULL;
  public function toSrcSet(string $src, ?int $intrinsicImageWidth = NULL): ?string {
    $template = NULL;

    // URLs with alternateWidths query parameter.
    $query = parse_url($src, PHP_URL_QUERY);
    if ($query) {
      parse_str($query, $params);
      if (!empty($params[ImageItemOverride::ALT_WIDTHS_QUERY_PARAM])) {
        // We only expect 1 `alternateWidths` query parameter.
        \assert(is_string($params[ImageItemOverride::ALT_WIDTHS_QUERY_PARAM]));
        $template = urldecode($params[ImageItemOverride::ALT_WIDTHS_QUERY_PARAM]);
      }
    }
    // Stream wrappers.
    elseif ($this->streamWrapperManager->isValidUri($src)) {
      $template = ParametrizedImageStyle::load('xb_parametrized_width')?->buildUrlTemplate($src);
      if (is_string($template)) {
        $template = $this->fileUrlGenerator->transformRelative($template);
      }
      // Respect the specified width, if any, but ensure that it's never bigger
      // than the actual image width.
      $actual_intrinsic_image_width = $this->getWidth($src);
      $intrinsicImageWidth = $intrinsicImageWidth === NULL
        ? $actual_intrinsic_image_width
        : min($intrinsicImageWidth, $actual_intrinsic_image_width);
      if (is_null($intrinsicImageWidth)) {
        $intrinsicImageWidth = $this->getWidth($src);
      }
    }

    parse_str($parts['query'], $query);
    // If no `alternateWidths`, return empty string.
    if (empty($query[ImageItemOverride::ALT_WIDTHS_QUERY_PARAM])) {
    if (empty($template) || empty($intrinsicImageWidth)) {
      return NULL;
    }

    // We only expect 1 `alternateWidths` query parameter.
    \assert(is_string($query[ImageItemOverride::ALT_WIDTHS_QUERY_PARAM]));
    $template = urldecode($query[ImageItemOverride::ALT_WIDTHS_QUERY_PARAM]);

    \assert(str_contains($template, '{width}'), "Expected '{width}' in template not found");

    // Filter widths smaller than or equal will be included to avoid generating
    // upscaled images.
    // Filter widths greater than the intrinsic width to avoid generating
    // upscaled images. We still create a srcset candidate when the width is the
    // same so we can do other things to it like convert it to a more optimized
    // format.
    // @todo Read this from third-party settings: https://drupal.org/i/3533563
    $widths = array_filter(ParametrizedImageStyleConverter::ALLOWED_WIDTHS, static fn($w) => $w < $intrinsicImageWidth);
    $widths = array_filter(ParametrizedImageStyleConverter::ALLOWED_WIDTHS, static fn($w) => $w <= $intrinsicImageWidth);

    $srcset = array_map(static fn($w) => str_replace('{width}', (string) $w, $template) . " {$w}w", $widths);
    return implode(', ', $srcset);
  }

  /**
   * Gets the width of an image from a given source path or URL.
   *
   * @param string $src
   *   The image source path, URL, or stream wrapper URI.
   *
   * @return int|null
   *   The width of the image in pixels, or NULL if the source is invalid
   *   or the image cannot be processed.
   */
  public function getWidth(string $src): ?int {
    if (UrlHelper::isValid($src) || $this->streamWrapperManager->isValidUri($src)) {
      $image = $this->imageFactory->get(ltrim($src, "/"));
      if ($image->isValid()) {
        return $image->getWidth();
      }
    }
    return NULL;
  }

  /**
   * Gets the height of an image from a given source path or URL.
   *
   * @param string $src
   *   The image source path, URL, or stream wrapper URI.
   *
   * @return int|null
   *   The height of the image in pixels, or NULL if the source is invalid
   *   or the image cannot be processed.
   */
  public function getHeight(string $src): ?int {
    if (UrlHelper::isValid($src) || $this->streamWrapperManager->isValidUri($src)) {
      $image = $this->imageFactory->get(ltrim($src, "/"));
      if ($image->isValid()) {
        return $image->getHeight();
      }
    }
    return NULL;
  }

}
+7 −0
Changes for src/Plugin/ExperienceBuilder/ComponentSource/SingleDirectoryComponent.php: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -313,6 +313,13 @@ final class SingleDirectoryComponent extends GeneratedFieldExplicitInputUxCompon
    if (isset($definition['status']) && $definition['status'] === 'obsolete') {
      throw new ComponentDoesNotMeetRequirementsException(['Component has "obsolete" status']);
    }
    // Special case exception for 'image' SDC, as noUi is not supported in
    // Drupal core < 11.3.
    // @todo Remove in https://www.drupal.org/i/3537695
    if ($definition['id'] === 'experience_builder:image') {
      return;
    }

    // Special case exception for 'all-props' SDC.
    // (This is used to develop support for more prop shapes.)
    if ($definition['id'] === 'sdc_test_all_props:all-props') {
Loading
Loading