Responsive images support

Created on 9 April 2019, over 5 years ago
Updated 3 October 2023, about 1 year ago

Are there any initiatives on supporting responsive images (using HTML picture element)? I cannot find it in the documentation and as it is supported in Drupal core it seems like a much appreciated feature for this module.

✨ Feature request
Status

Active

Version

2.0

Component

Code

Created by

πŸ‡³πŸ‡±Netherlands marcus_w

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • πŸ‡§πŸ‡ͺBelgium nightlife2008

    I'm currently doing a D10 project using Bynder and I came to the same requirement of having responsive images with different aspect ratio's for different screensizes, so I decided to create a (rough) formatter to accomodate this somehow by using different DAT queries for different breakpoints / multipliers. This formatter outputs a picture element just like core's Responsive Image module.

    I reused some bits of the Responsive Image UI and the original formatter:

    Formatter:

    <?php
    
    namespace Drupal\MODULE\Plugin\Field\FieldFormatter;
    
    use Drupal\bynder\Plugin\Field\FieldFormatter\BynderFormatterBase;
    use Drupal\bynder\Plugin\media\Source\Bynder;
    use Drupal\Component\Utility\NestedArray;
    use Drupal\Core\Access\AccessResult;
    use Drupal\Core\Field\FieldItemListInterface;
    use Drupal\Core\Form\FormStateInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    
    /**
     * Plugin implementation of the 'Bynder' formatter.
     *
     * @FieldFormatter(
     *   id = "bynder_responsive_image",
     *   label = @Translation("Bynder (Image, Responsive picture)"),
     *   field_types = {"string", "string_long", "entity_reference"}
     * )
     */
    class BynderFormatterResponsiveImage extends BynderFormatterBase {
    
      /**
       * The entity repository service.
       *
       * @var \Drupal\Core\Entity\EntityRepositoryInterface
       */
      protected $entityRepository;
    
      /**
       * The Breakpoint manager service.
       *
       * @var \Drupal\breakpoint\BreakpointManagerInterface
       */
      protected $breakpointManager;
    
      /**
       * {@inheritdoc}
       */
      public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
        $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
    
        $instance->entityRepository = $container->get('entity.repository');
        $instance->breakpointManager = $container->get('breakpoint.manager');
    
        return $instance;
      }
    
      /**
       * {@inheritdoc}
       */
      public static function defaultSettings() {
        return [
          'alt_field' => '',
          'title_field' => '',
          'dat_query' => '',
          'breakpoint_group' => 'responsive_image',
          'dat_query_responsive' => [],
        ] + parent::defaultSettings();
      }
    
      /**
       * {@inheritdoc}
       */
      public function settingsForm(array $form, FormStateInterface $form_state) {
        $form = parent::settingsForm($form, $form_state);
    
        $field_candidates = $this->getAttributesFieldsCandidates();
        $form['alt_field'] = [
          '#type' => 'select',
          '#options' => $field_candidates,
          '#title' => $this->t('Alt attribute field'),
          '#description' => $this->t('Select the name of the field that should be used for the "alt" attribute of the image.'),
          '#default_value' => $this->getSetting('alt_field'),
          '#empty_value' => '',
        ];
    
        $form['title_field'] = [
          '#type' => 'select',
          '#options' => $field_candidates,
          '#title' => $this->t('Title attribute field'),
          '#description' => $this->t('Select the name of the field that should be used for the "title" attribute of the image.'),
          '#default_value' => $this->getSetting('title_field'),
          '#empty_value' => '',
        ];
    
        $dat_documentation = 'https://support.bynder.com/hc/en-us/articles/360018559260-Dynamic-Asset-Transformations-DAT-';
        $form['dat_query'] = [
          '#type' => 'textfield',
          '#title' => $this->t('DAT query field'),
          '#description' => $this->t('Attributes that should be applied to the images. See  <a href=":dat_help">here</a> for explanation on possible values. Should start right after the "?", e.g. "io=transform:fill,width:100,height:200" If the following Responsive image fields are filled, this field defines the fallback image if the responsive settings are broken.', [
            ':dat_help' => $dat_documentation,
          ]),
          '#default_value' => $this->getSetting('dat_query'),
          '#states' => [
            'visible' => [
              ':input.bynder-derivative' => ['value' => 'DAT'],
            ],
            'required' => [
              ':input.bynder-derivative' => ['value' => 'DAT'],
            ],
          ],
        ];
    
        $breakpoint_group = $this->getSettingFromFormState($form_state, 'breakpoint_group');
        $form['breakpoint_group'] = [
          '#type' => 'select',
          '#title' => $this->t('Breakpoint group'),
          '#default_value' => $breakpoint_group ?? 'responsive_image',
          '#options' => $this->breakpointManager->getGroups(),
          '#required' => TRUE,
          '#ajax' => [
            'callback' => [get_class(), 'breakpointMappingFormAjax'],
            'wrapper' => 'ajax-responsive-query-settings',
            'method' => 'replace',
          ],
        ];
    
        $form['dat_query_responsive'] = [
          '#type' => 'container',
          '#weight' => 100,
          '#tree' => TRUE,
          '#attributes' => [
            'id' => 'ajax-responsive-query-settings',
          ],
          '#states' => [
            'visible' => [
              ':input.bynder-derivative' => ['value' => 'DAT'],
            ],
          ],
        ];
    
        if (empty($breakpoint_group)) {
          return $form;
        }
    
        $breakpoints = $this->breakpointManager->getBreakpointsByGroup($breakpoint_group);
    
        $dat_query_mapping = $this->getSetting('dat_query_responsive') ?? [];
    
        foreach ($breakpoints as $breakpoint_id => $breakpoint) {
          foreach ($breakpoint->getMultipliers() as $multiplier) {
            $sanitized_breakpoint_id = str_replace(['-', '.'], '_', $breakpoint_id);
            $sanitized_multiplier = str_replace(['-', '.'], '_', $multiplier);
            $item_mapping = $dat_query_mapping[$sanitized_breakpoint_id][$sanitized_multiplier] ?? [];
            $item_mapping_dat_query = isset($item_mapping['dat_query']) && is_string($item_mapping['dat_query']) ? $item_mapping['dat_query'] : NULL;
    
            $label = $multiplier . ' ' . $breakpoint->getLabel() . ' [' . $breakpoint->getMediaQuery() . ']';
            $form['dat_query_responsive'][$sanitized_breakpoint_id][$sanitized_multiplier] = [
              '#type' => 'details',
              '#title' => $label,
              '#open' => !empty($item_mapping_dat_query),
            ];
    
            $form['dat_query_responsive'][$sanitized_breakpoint_id][$sanitized_multiplier]['dat_query'] = [
              '#type' => 'textfield',
              '#title' => $this->t('Query'),
              '#default_value' => $item_mapping_dat_query,
            ];
          }
        }
    
        return $form;
      }
    
      /**
       * Ajax callback to refresh the breakpoint mapping form.
       */
      public static function breakpointMappingFormAjax($form, FormStateInterface $form_state) {
        $triggeringElement = $form_state->getTriggeringElement();
        // Dynamically return the dependent ajax for elements based on the
        // triggering element. This shouldn't be done statically because
        // settings forms may be different, e.g. for layout builder, core, ...
        if (!empty($triggeringElement['#array_parents'])) {
          $subformKeys = $triggeringElement['#array_parents'];
          // Remove the triggering element itself:
          array_pop($subformKeys);
          $subformKeys[] = 'dat_query_responsive';
          // Return the subform:
          return NestedArray::getValue($form, $subformKeys);
        }
      }
    
      /**
       * Get a setting value from form state or from our settings.
       *
       * @param \Drupal\Core\Form\FormStateInterface $form_state
       *   The FormState object.
       * @param $setting
       *   The setting key.
       *
       * @return mixed|null
       *   The value.
       */
      protected function getSettingFromFormState(FormStateInterface $form_state, $setting) {
        $field_name = $this->fieldDefinition->getName();
        if ($form_state->hasValue(['fields', $field_name, 'settings_edit_form', 'settings', $setting])) {
          return $form_state->getValue(['fields', $field_name, 'settings_edit_form', 'settings', $setting]);
        }
        return $this->getSetting($setting);
      }
    
      /**
       * {@inheritdoc}
       */
      public function settingsSummary() {
        $summary = parent::settingsSummary();
    
        $settings = $this->getSettings();
    
        if (!empty($settings['dat_query_responsive'])) {
          $summary[] = $this->t('DAT configuration: Responsive, fallback: @dat', ['@dat' => $settings['dat_query']]);
        }
        else {
          $summary[] = $this->t('Fallback DAT configuration: @dat', ['@dat' => $settings['dat_query']]);
        }
    
        $field_candidates = $this->getAttributesFieldsCandidates();
        if (empty($settings['title_field'])) {
          $summary[] = $this->t('Title attribute not displayed (not recommended).');
        }
        else {
          $summary[] = $this->t('Title attribute field: @field', ['@field' => $field_candidates[$settings['title_field']]]);
        }
    
        if (empty($settings['alt_field'])) {
          $summary[] = $this->t('Alt attribute not displayed (not recommended).');
        }
        else {
          $summary[] = $this->t('Alt attribute field: @field', ['@field' => $field_candidates[$settings['alt_field']]]);
        }
    
        return $summary;
      }
    
      /**
       * {@inheritdoc}
       */
      public function viewElements(FieldItemListInterface $items, $langcode) {
        $settings = $this->getSettings();
        $elements = [];
        $is_entityreference = $this->fieldDefinition->getType() == 'entity_reference';
    
        foreach ($items as $delta => $item) {
          /** @var \Drupal\media\MediaInterface $media_entity */
          if ($media_entity = $is_entityreference ? $item->entity : $items->getEntity()) {
            /** @var \Drupal\media\MediaInterface $media_entity */
            $media_entity = $this->entityRepository->getTranslationFromContext($media_entity, $langcode);
            /** @var \Drupal\media\MediaSourceInterface $source_plugin */
            $source_plugin = $media_entity->getSource();
    
            if ($source_plugin instanceof Bynder && ($thumbnails = $source_plugin->getMetadata($media_entity, 'thumbnail_urls'))) {
              $thumbnail_uri = NULL;
    
              if (isset($thumbnails['transformBaseUrl']) && !empty($settings['dat_query'])) {
                $thumbnail_uri = $thumbnails['transformBaseUrl'] . '?' . $this->getSetting('dat_query');
              }
    
              $attributes = [];
              $attributes['loading'] = 'lazy';
    
              if ($settings['title_field'] && $media_entity->hasField($settings['title_field']) && !$media_entity->get($settings['title_field'])->isEmpty()) {
                $attributes['title'] = $media_entity->get($settings['title_field'])->value;
              }
              if ($settings['alt_field'] && $media_entity->hasField($settings['alt_field']) && !$media_entity->get($settings['alt_field'])->isEmpty()) {
                $attributes['alt'] = $media_entity->get($settings['alt_field'])->value;
              }
    
              $elements[$delta] = [
                '#theme' => 'bynder_responsive_image',
                '#uri' => $thumbnail_uri,
                '#attributes' => $attributes,
                '#breakpoint_group' => $this->getSetting('breakpoint_group'),
                '#dat_base_url' => $thumbnails['transformBaseUrl'],
                '#dat_query_responsive' => $this->getSetting('dat_query_responsive'),
              ];
    
              $this->renderer->addCacheableDependency($elements[$delta], $item);
            }
          }
        }
    
        return $elements;
      }
    
    }
    
    

    Theme hook:

    /**
     * Implements hook_theme().
     */
    function MODULE_theme($existing, $type, $theme, $path) {
      return [
        'bynder_responsive_image' => [
          'variables' => [
            'uri' => NULL,
            'attributes' => [],
            'breakpoint_group' => [],
            'dat_base_url' => '',
            'dat_query_responsive' => [],
            'height' => NULL,
            'width' => NULL,
          ],
        ],
      ];
    }
    

    A preprocess function for our template:

    /**
     * Implements hook_preprocess_hook().
     */
    function template_preprocess_bynder_responsive_image(array &$variables) {
      // Make sure that width and height are proper values
      // If they exists we'll output them.
      // @see http://www.w3.org/community/respimg/2012/06/18/florians-compromise/
      if (isset($variables['width']) && empty($variables['width'])) {
        unset($variables['width']);
        unset($variables['height']);
      }
      elseif (isset($variables['height']) && empty($variables['height'])) {
        unset($variables['width']);
        unset($variables['height']);
      }
    
      // Retrieve all breakpoints and multipliers and reverse order of breakpoints.
      // By default, breakpoints are ordered from smallest weight to largest:
      // the smallest weight is expected to have the smallest breakpoint width,
      // while the largest weight is expected to have the largest breakpoint
      // width. For responsive images, we need largest breakpoint widths first, so
      // we need to reverse the order of these breakpoints.
      $dat_query_mapping = $variables['dat_query_responsive'];
      $breakpoint_group = $variables['breakpoint_group'];
    
      $breakpoints = array_reverse(\Drupal::service('breakpoint.manager')->getBreakpointsByGroup($breakpoint_group));
      foreach ($breakpoints as $breakpoint_id => $breakpoint) {
        $sanitized_breakpoint_id = str_replace(['-', '.'], '_', $breakpoint_id);
        $multipliers = $dat_query_mapping[$sanitized_breakpoint_id];
    
        if (isset($dat_query_mapping[$sanitized_breakpoint_id])) {
          if ($sources = _MODULE_bynder_responsive_image_build_source_attributes($variables, $breakpoint, $multipliers)) {
            $variables['sources'][] = $sources;
          }
        }
      }
    
      if (isset($variables['sources']) && count($variables['sources']) === 1 && !isset($variables['sources'][0]['media'])) {
        // There is only one source tag with an empty media attribute. This means
        // we can output an image tag with the srcset attribute instead of a
        // picture tag.
        $variables['output_image_tag'] = TRUE;
        foreach ($variables['sources'][0] as $attribute => $value) {
          if ($attribute != 'type') {
            $variables['attributes'][$attribute] = $value;
          }
        }
    
        $variables['img_element'] = [
          '#theme' => 'image',
          '#uri' => $variables['uri'],
          '#attributes' => [],
        ];
      }
      else {
        $variables['output_image_tag'] = FALSE;
        // Prepare the fallback image. We use the src attribute, which might cause
        // double downloads in browsers that don't support the picture tag.
        $variables['img_element'] = [
          '#theme' => 'image',
          '#uri' => $variables['uri'],
          '#attributes' => [],
        ];
      }
    
      if (isset($variables['attributes'])) {
        if (isset($variables['attributes']['alt'])) {
          $variables['img_element']['#alt'] = $variables['attributes']['alt'];
          unset($variables['attributes']['alt']);
        }
        if (isset($variables['attributes']['title'])) {
          $variables['img_element']['#title'] = $variables['attributes']['title'];
          unset($variables['attributes']['title']);
        }
        $variables['img_element']['#attributes'] = $variables['attributes'];
      }
    }
    

    The mentioned helper function:

    
    function _MODULE_bynder_responsive_image_build_source_attributes(array $variables, BreakpointInterface $breakpoint, array $multipliers) {
      $srcset = [];
      $dat_base_url = $variables['dat_base_url'];
    
      // Traverse the multipliers in reverse so the largest image is processed last.
      // The last image's dimensions are used for img.srcset height and width.
      foreach (array_reverse($multipliers) as $multiplier => $multiplier_settings) {
        if (!empty($multiplier_settings['dat_query'])) {
          $srcset[intval(mb_substr($multiplier, 0, -1) * 100)] = str_replace('io=', $dat_base_url . '?io=', $multiplier_settings['dat_query']);
        }
      }
    
      if (empty($srcset)) {
        return NULL;
      }
    
      // Sort the srcset from small to large image width or multiplier.
      ksort($srcset);
      $source_attributes = new Attribute([
        'srcset' => implode(', ', array_unique($srcset)),
      ]);
      $media_query = trim($breakpoint->getMediaQuery());
      if (!empty($media_query)) {
        $source_attributes->setAttribute('media', $media_query);
      }
    
      return $source_attributes;
    }
    

    Finally the Twig template in our module's "templates" dir:

    {#
    /**
     * @file
     * Default theme implementation of a responsive image.
     *
     * Available variables:
     * - sources: The attributes of the <source> tags for this <picture> tag.
     * - img_element: The controlling image, with the fallback image in srcset.
     * - output_image_tag: Whether or not to output an <img> tag instead of a
     *   <picture> tag.
     *
     * @see template_preprocess()
     * @see template_preprocess_responsive_image()
     *
     * @ingroup themeable
     */
    #}
    {% if output_image_tag %}
      {{ img_element }}
    {% else %}
      <picture>
        {% if sources %}
          {% for source_attributes in sources %}
            <source{{ source_attributes }}/>
          {% endfor %}
        {% endif %}
        {# The controlling image, with the fallback image in srcset. #}
        {{ img_element }}
      </picture>
    {% endif %}
    
    

    I hope I can help someone looking for the same functionality.

    The settings UI can be seen in the attached screenshots.

  • πŸ‡ΊπŸ‡ΈUnited States safetypin Memphis, Tennessee

    Has anyone considered adding support for the Image Replace Effect module? It can integrate already with an image source media entity, so I would imagine it might not be too hard to extend support to Bynder media entities. I'll start to look into it, and if I develop anything useful I'll start a new thread. Not sure if it should be a patch to this module, a patch to the Replace Effect module, or a whole 'nother module all together.

Production build 0.71.5 2024