diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js index 31bfe22d3b..a6b96894d9 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js @@ -163,6 +163,13 @@ }, }, + getLabel() { + if (this.data.label) { + return this.data.label; + } + return Drupal.t('Embedded media'); + }, + upcast(element, data) { const { attributes } = element; // This matches the behavior of the corresponding server-side text filter plugin. @@ -181,6 +188,7 @@ if (data.hasCaption && data.attributes['data-caption'] === '') { data.attributes['data-caption'] = ' '; } + data.label = null; data.link = null; if (element.parent.name === 'a') { data.link = CKEDITOR.tools.copy(element.parent.attributes); @@ -459,6 +467,9 @@ const dataToHash = CKEDITOR.tools.clone(data); // The caption does not need rendering. delete dataToHash.attributes['data-caption']; + // The media entity's label is server-side data and cannot be + // modified by the content author. + delete dataToHash.label; // Changed link destinations do not affect the visual preview. if (dataToHash.link) { delete dataToHash.link.href; @@ -485,8 +496,9 @@ text: this.downcast().getOuterHtml(), }, dataType: 'html', - success: previewHtml => { + success: (previewHtml, textStatus, jqXhr) => { this.element.setHtml(previewHtml); + this.setData('label', jqXhr.getResponseHeader('Drupal-Media-Label')); callback(this); }, error: () => { diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.js b/core/modules/media/js/plugins/drupalmedia/plugin.js index f95d963d7f..57726031d5 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.js @@ -124,6 +124,12 @@ } }, + getLabel: function getLabel() { + if (this.data.label) { + return this.data.label; + } + return Drupal.t('Embedded media'); + }, upcast: function upcast(element, data) { var attributes = element.attributes; @@ -136,6 +142,7 @@ if (data.hasCaption && data.attributes['data-caption'] === '') { data.attributes['data-caption'] = ' '; } + data.label = null; data.link = null; if (element.parent.name === 'a') { data.link = CKEDITOR.tools.copy(element.parent.attributes); @@ -299,6 +306,8 @@ delete dataToHash.attributes['data-caption']; + delete dataToHash.label; + if (dataToHash.link) { delete dataToHash.link.href; } @@ -313,8 +322,9 @@ text: this.downcast().getOuterHtml() }, dataType: 'html', - success: function success(previewHtml) { + success: function success(previewHtml, textStatus, jqXhr) { _this3.element.setHtml(previewHtml); + _this3.setData('label', jqXhr.getResponseHeader('Drupal-Media-Label')); callback(_this3); }, error: function error() { diff --git a/core/modules/media/src/Controller/MediaFilterController.php b/core/modules/media/src/Controller/MediaFilterController.php index 3350b32e78..309a1a2195 100644 --- a/core/modules/media/src/Controller/MediaFilterController.php +++ b/core/modules/media/src/Controller/MediaFilterController.php @@ -4,6 +4,8 @@ use Drupal\Core\Access\AccessResult; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\ContentEntityStorageInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Render\RendererInterface; use Drupal\filter\FilterFormatInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -28,14 +30,34 @@ class MediaFilterController implements ContainerInjectionInterface { */ protected $renderer; + /** + * The media storage. + * + * @var \Drupal\Core\Entity\ContentEntityStorageInterface + */ + protected $mediaStorage; + + /** + * The entity repository. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + /** * Constructs an MediaFilterController instance. * * @param \Drupal\Core\Render\RendererInterface $renderer * The renderer service. + * @param \Drupal\Core\Entity\ContentEntityStorageInterface + * The media storage. + * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository + * The entity repository. */ - public function __construct(RendererInterface $renderer) { + public function __construct(RendererInterface $renderer, ContentEntityStorageInterface $media_storage, EntityRepositoryInterface $entity_repository) { $this->renderer = $renderer; + $this->mediaStorage = $media_storage; + $this->entityRepository = $entity_repository; } /** @@ -43,7 +65,9 @@ public function __construct(RendererInterface $renderer) { */ public static function create(ContainerInterface $container) { return new static( - $container->get('renderer') + $container->get('renderer'), + $container->get('entity_type.manager')->getStorage('media'), + $container->get('entity.repository') ); } @@ -81,12 +105,24 @@ public function preview(Request $request, FilterFormatInterface $filter_format) ]; $html = $this->renderer->renderPlain($build); + // Use the bubbled cache tags to determine which media was rendered (if any) + // so we can embed the label in the response, for use in an ARIA label. + $headers = []; + $media_cache_tags = array_filter($build['#cache']['tags'], function ($tag) { + return strpos($tag, 'media:') === 0; + }); + if (!empty($media_cache_tags)) { + $embedded_media_id = substr(reset($media_cache_tags), 6); + $media = $this->mediaStorage->load($embedded_media_id); + $headers['Drupal-Media-Label'] = $this->entityRepository->getTranslationFromContext($media)->label(); + } + // Note that we intentionally do not use: // - \Drupal\Core\Cache\CacheableResponse because caching it on the server // side is wasteful, hence there is no need for cacheability metadata. // - \Drupal\Core\Render\HtmlResponse because there is no need for // attachments nor cacheability metadata. - return (new Response($html)) + return (new Response($html, 200, $headers)) // Do not allow any intermediary to cache the response, only the end user. ->setPrivate() // Allow the end user to cache it for up to 5 minutes. diff --git a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php index 1e651dbc98..efc7b7b8de 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php @@ -243,7 +243,6 @@ public function testPreviewUsesDefaultThemeAndIsClientCacheable() { $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]')); $element = $assert_session->elementExists('css', '[data-media-embed-test-active-theme]'); $this->assertSame('stable', $element->getAttribute('data-media-embed-test-active-theme')); - // Assert that the first preview request transferred >500 B over the wire. // Then toggle source mode on and off. This causes the CKEditor widget to be // destroyed and then reconstructed. Assert that during this reconstruction, @@ -276,6 +275,8 @@ public function testEditableCaption() { $this->assignNameToCkeditorIframe(); $this->getSession()->switchToIFrame('ckeditor'); $this->assertNotEmpty($assert_session->waitForButton('Edit media')); + // Test `aria-label` attribute appears on the widget wrapper. + $assert_session->elementExists('css', '.cke_widget_drupalmedia[aria-label="Screaming hairy armadillo"]'); $assert_session->elementContains('css', 'figcaption', ''); $assert_session->elementAttributeContains('css', 'figcaption', 'data-placeholder', 'Enter caption here'); // Test if you leave the caption blank, but change another attribute, @@ -465,6 +466,8 @@ public function testDialogAccess() { $this->assignNameToCkeditorIframe(); $this->getSession()->switchToIFrame('ckeditor'); $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + // Test `aria-label` attribute appears on the widget wrapper. + $assert_session->elementExists('css', '.cke_widget_drupalmedia[aria-label="Screaming hairy armadillo"]'); $page->pressButton('Edit media'); $this->waitForMetadataDialog(); $assert_session->fieldNotExists('attributes[alt]'); @@ -625,6 +628,8 @@ public function testAlt() { // Assert that the img within the media embed within the CKEditor contains // the overridden alt text set in the dialog. $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media img[alt*="' . $who_is_zartan . '"]')); + // Test `aria-label` attribute appears on the widget wrapper. + $assert_session->elementExists('css', '.cke_widget_drupalmedia[aria-label="Screaming hairy armadillo"]'); // Test that the downcast drupal-media element now has the alt attribute // entered in the dialog. @@ -759,6 +764,8 @@ public function testTranslationAlt() { // Test that the default alt attribute displays without an override. $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "texte alternatif par défaut")]')); + // Test `aria-label` attribute appears on the widget wrapper. + $assert_session->elementExists('css', '.cke_widget_drupalmedia[aria-label="Tatou poilu hurlant"]'); $page->pressButton('Edit media'); $this->waitForMetadataDialog(); // Assert that the placeholder is set to the value of the media field's @@ -1135,6 +1142,7 @@ public function testViewMode() { $this->assignNameToCkeditorIframe(); $this->getSession()->switchToIFrame('ckeditor'); $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media')); + $assert_session->elementExists('css', '.cke_widget_drupalmedia[aria-label="Screaming hairy armadillo"]'); $page->pressButton('Edit media'); $this->waitForMetadataDialog(); $assert_session->optionExists('attributes[data-view-mode]', 'view_mode_1');