diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php
index 76c40a2ce21..61b337f1fb6 100644
--- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php
+++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php
@@ -543,7 +543,7 @@ JS;
$uploaded_image = File::load(1);
$image_url = $this->container->get('file_url_generator')->generateString($uploaded_image->getFileUri());
$image_uuid = $uploaded_image->uuid();
- $assert_session->elementExists('xpath', sprintf('//img[@src="%s" and @loading="lazy" and @width and @height and @data-entity-uuid="%s" and @data-entity-type="file"]', $image_url, $image_uuid));
+ $assert_session->elementExists('xpath', sprintf('//img[@src="%s" and @width and @height and @data-entity-uuid="%s" and @data-entity-type="file"]', $image_url, $image_uuid));
// Ensure that width, height, and length attributes are not stored in the
// database.
@@ -555,7 +555,7 @@ JS;
$this->assertNotEmpty($assert_session->waitForElement('css', '.ck-editor'));
$page->pressButton('Save');
- $assert_session->elementExists('xpath', sprintf('//img[@src="%s" and @loading="lazy" and @width and @height and @data-entity-uuid="%s" and @data-entity-type="file"]', $image_url, $image_uuid));
+ $assert_session->elementExists('xpath', sprintf('//img[@src="%s" and @width and @height and @data-entity-uuid="%s" and @data-entity-type="file"]', $image_url, $image_uuid));
}
/**
diff --git a/core/modules/editor/editor.post_update.php b/core/modules/editor/editor.post_update.php
index 71814f3101f..112409ecb0e 100644
--- a/core/modules/editor/editor.post_update.php
+++ b/core/modules/editor/editor.post_update.php
@@ -5,6 +5,10 @@
* Post update functions for Editor.
*/
+use Drupal\filter\Entity\FilterFormat;
+use Drupal\filter\FilterFormatInterface;
+use Drupal\filter\FilterPluginCollection;
+
/**
* Implements hook_removed_post_updates().
*/
@@ -13,3 +17,30 @@ function editor_removed_post_updates() {
'editor_post_update_clear_cache_for_file_reference_filter' => '9.0.0',
];
}
+
+/**
+ * Enable filter_image_lazy_load if editor_file_reference is enabled.
+ */
+function editor_post_update_image_lazy_load(): void {
+ if (\Drupal::service('plugin.manager.filter')->hasDefinition('editor_file_reference')) {
+ foreach (FilterFormat::loadMultiple() as $format) {
+ assert($format instanceof FilterFormatInterface);
+ $collection = $format->filters();
+ $configuration = $collection->getConfiguration();
+ assert($collection instanceof FilterPluginCollection);
+ if (array_key_exists('editor_file_reference', $configuration)) {
+ $collection->addInstanceId('filter_image_lazy_load');
+ $configuration['filter_image_lazy_load'] = [
+ 'id' => 'filter_image_lazy_load',
+ 'provider' => 'editor',
+ 'status' => TRUE,
+ // Place lazy loading after editor file reference.
+ 'weight' => $configuration['editor_file_reference']['weight'] + 1,
+ 'settings' => [],
+ ];
+ $collection->setConfiguration($configuration);
+ $format->save();
+ }
+ }
+ }
+}
diff --git a/core/modules/editor/src/Plugin/Filter/EditorFileReference.php b/core/modules/editor/src/Plugin/Filter/EditorFileReference.php
index e41901f7c41..d107b95347b 100644
--- a/core/modules/editor/src/Plugin/Filter/EditorFileReference.php
+++ b/core/modules/editor/src/Plugin/Filter/EditorFileReference.php
@@ -19,7 +19,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
* @Filter(
* id = "editor_file_reference",
* title = @Translation("Track images uploaded via a Text Editor"),
- * description = @Translation("Ensures that the latest versions of images uploaded via a Text Editor are displayed."),
+ * description = @Translation("Ensures that the latest versions of images uploaded via a Text Editor are displayed, along with their dimensions."),
* type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE
* )
*/
@@ -92,22 +92,16 @@ class EditorFileReference extends FilterBase implements ContainerFactoryPluginIn
if ($file instanceof FileInterface) {
$node->setAttribute('src', $file->createFileUrl());
if ($node->nodeName == 'img') {
- // Without dimensions specified, layout shifts can occur,
- // which are more noticeable on pages that take some time to load.
- // As a result, only mark images as lazy load that have dimensions.
$image = $this->imageFactory->get($file->getFileUri());
$width = $image->getWidth();
$height = $image->getHeight();
- if ($width !== NULL && $height !== NULL) {
- if (!$node->hasAttribute('width')) {
- $node->setAttribute('width', $width);
- }
- if (!$node->hasAttribute('height')) {
- $node->setAttribute('height', $height);
- }
- if (!$node->hasAttribute('loading')) {
- $node->setAttribute('loading', 'lazy');
- }
+ // Set dimensions to avoid content layout shift (CLS).
+ // @see https://web.dev/cls/
+ if ($width !== NULL && !$node->hasAttribute('width')) {
+ $node->setAttribute('width', (string) $width);
+ }
+ if ($height !== NULL && !$node->hasAttribute('height')) {
+ $node->setAttribute('height', (string) $height);
}
}
}
diff --git a/core/modules/editor/tests/src/Functional/Update/EditorAddLazyLoadImageFilterUpdateTest.php b/core/modules/editor/tests/src/Functional/Update/EditorAddLazyLoadImageFilterUpdateTest.php
new file mode 100644
index 00000000000..a4e9b659b15
--- /dev/null
+++ b/core/modules/editor/tests/src/Functional/Update/EditorAddLazyLoadImageFilterUpdateTest.php
@@ -0,0 +1,48 @@
+databaseDumpFiles = [
+ __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.bare.standard.php.gz',
+ ];
+ }
+
+ /**
+ * Tests upgrading filter settings.
+ *
+ * @see editor_post_update_image_lazy_load()
+ */
+ public function testUpdateLazyImageLoad(): void {
+ $config = $this->config('filter.format.full_html');
+ $this->assertArrayNotHasKey('filter_image_lazy_load', $config->get('filters'));
+
+ $this->runUpdates();
+
+ $config = $this->config('filter.format.full_html');
+ $filters = $config->get('filters');
+ $this->assertArrayHasKey('filter_image_lazy_load', $filters);
+ $this->assertEquals($filters['editor_file_reference']['weight'] + 1, $filters['filter_image_lazy_load']['weight']);
+ }
+
+}
diff --git a/core/modules/editor/tests/src/Kernel/EditorFileReferenceFilterTest.php b/core/modules/editor/tests/src/Kernel/EditorFileReferenceFilterTest.php
index 19877a6fa93..b764eedaef8 100644
--- a/core/modules/editor/tests/src/Kernel/EditorFileReferenceFilterTest.php
+++ b/core/modules/editor/tests/src/Kernel/EditorFileReferenceFilterTest.php
@@ -130,7 +130,7 @@ class EditorFileReferenceFilterTest extends KernelTestBase {
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
- // Add a valid image for test lazy loading feature.
+ // Add a valid image for image dimension testing.
/** @var array stdClass */
$files = $this->getTestFiles('image');
$image = reset($files);
@@ -138,16 +138,16 @@ class EditorFileReferenceFilterTest extends KernelTestBase {
[$width, $height] = getimagesize('public://llama.jpg');
$dimensions = 'width="' . $width . '" height="' . $height . '"';
- // Image dimensions and loading attributes are present.
+ // Image dimensions are present.
$input = '';
- $expected_output = '
';
+ $expected_output = '
';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
- // Image dimensions and loading attributes are set manually.
- $input = '
';
- $expected_output = '
';
+ // Image dimensions are set manually.
+ $input = '
';
+ $expected_output = '
';
$output = $test($input);
$this->assertSame($expected_output, $output->getProcessedText());
$this->assertEquals($cache_tag, $output->getCacheTags());
diff --git a/core/modules/filter/src/Plugin/Filter/FilterImageLazyLoad.php b/core/modules/filter/src/Plugin/Filter/FilterImageLazyLoad.php
new file mode 100644
index 00000000000..ee0802e1c8a
--- /dev/null
+++ b/core/modules/filter/src/Plugin/Filter/FilterImageLazyLoad.php
@@ -0,0 +1,59 @@
+<img loading="eager">."),
+ * type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE,
+ * weight = 15
+ * )
+ */
+final class FilterImageLazyLoad extends FilterBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function process($text, $langcode): FilterProcessResult {
+ $result = new FilterProcessResult($text);
+
+ // If there are no images, return early.
+ if (stripos($text, '
setProcessedText($this->transformImages($text));
+ }
+
+ /**
+ * Transform markup of images to include loading="lazy".
+ *
+ * @param string $text
+ * The markup to transform.
+ *
+ * @return string
+ * The transformed text with loading attribute added.
+ */
+ private function transformImages(string $text): string {
+ $dom = Html::load($text);
+ $xpath = new \DOMXPath($dom);
+ // Only set loading="lazy" if no existing loading attribute is specified and
+ // dimensions are specified.
+ foreach ($xpath->query('//img[not(@loading="eager") and @width and @height]') as $element) {
+ assert($element instanceof \DOMElement);
+ $element->setAttribute('loading', 'lazy');
+ }
+ return Html::serialize($dom);
+ }
+
+}
diff --git a/core/modules/filter/tests/src/Unit/FilterImageLazyLoadTest.php b/core/modules/filter/tests/src/Unit/FilterImageLazyLoadTest.php
new file mode 100644
index 00000000000..6da46a4a55f
--- /dev/null
+++ b/core/modules/filter/tests/src/Unit/FilterImageLazyLoadTest.php
@@ -0,0 +1,86 @@
+filter = new FilterImageLazyLoad([], 'filter_image_lazy_load', ['provider' => 'test']);
+ parent::setUp();
+ }
+
+ /**
+ * @covers ::process
+ *
+ * @dataProvider providerHtml
+ *
+ * @param string $html
+ * Input HTML.
+ * @param string $expected
+ * The expected output string.
+ */
+ public function testProcess(string $html, string $expected): void {
+ $this->assertSame($expected, $this->filter->process($html, 'en')->getProcessedText());
+ }
+
+ /**
+ * Provides data for testProcess.
+ *
+ * @return array
+ * An array of test data.
+ */
+ public function providerHtml(): array {
+ return [
+ 'lazy loading attribute already added' => [
+ 'input' => '
Lorem ipsum...
', + 'output' => 'Lorem ipsum...
', + ], + 'no image dimensions provided' => [ + 'input' => '