diff --git a/core/modules/media/css/filter.caption.css b/core/modules/media/css/filter.caption.css new file mode 100644 index 000000000000..a92505c30849 --- /dev/null +++ b/core/modules/media/css/filter.caption.css @@ -0,0 +1,10 @@ +/** + * @file + * Caption filter: default styling for displaying Media Embed captions. + */ + +.caption .media .field, +.caption .media .field * { + float: none; + margin: unset; +} diff --git a/core/modules/media/media.libraries.yml b/core/modules/media/media.libraries.yml index 6123186360b4..686e77452136 100644 --- a/core/modules/media/media.libraries.yml +++ b/core/modules/media/media.libraries.yml @@ -23,3 +23,11 @@ oembed.frame: css: component: css/oembed.frame.css: {} + +filter.caption: + version: VERSION + css: + component: + css/filter.caption.css: {} + dependencies: + - filter/caption diff --git a/core/modules/media/media.module b/core/modules/media/media.module index 48ef64777f73..8f67df37030e 100644 --- a/core/modules/media/media.module +++ b/core/modules/media/media.module @@ -359,3 +359,140 @@ function media_entity_type_alter(array &$entity_types) { $entity_type->setLinkTemplate('canonical', '/media/{media}'); } } + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function media_form_filter_format_edit_form_alter(array &$form, FormStateInterface $form_state, $form_id) { + // Add an additional validate callback so we can ensure the order of filters + // is correct. + $form['#validate'][] = 'media_filter_format_edit_form_validate'; +} + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function media_form_filter_format_add_form_alter(array &$form, FormStateInterface $form_state, $form_id) { + // Add an additional validate callback so we can ensure the order of filters + // is correct. + $form['#validate'][] = 'media_filter_format_edit_form_validate'; +} + +/** + * Validate callback to ensure filter order and allowed_html are compatible. + */ +function media_filter_format_edit_form_validate($form, FormStateInterface $form_state) { + if ($form_state->getTriggeringElement()['#name'] !== 'op') { + return; + } + + $allowed_html_path = [ + 'filters', + 'filter_html', + 'settings', + 'allowed_html', + ]; + + $filter_html_settings_path = [ + 'filters', + 'filter_html', + 'settings', + ]; + + $filter_html_enabled = $form_state->getValue([ + 'filters', + 'filter_html', + 'status', + ]); + + $media_embed_enabled = $form_state->getValue([ + 'filters', + 'media_embed', + 'status', + ]); + + if (!$media_embed_enabled) { + return; + } + + $get_filter_label = function ($filter_plugin_id) use ($form) { + return (string) $form['filters']['order'][$filter_plugin_id]['filter']['#markup']; + }; + + if ($filter_html_enabled && $allowed_html = $form_state->getValue($allowed_html_path)) { + /** @var \Drupal\filter\Entity\FilterFormat $filter_format */ + $filter_format = $form_state->getFormObject()->getEntity(); + + $filter_html = clone $filter_format->filters()->get('filter_html'); + $filter_html->setConfiguration(['settings' => $form_state->getValue($filter_html_settings_path)]); + $restrictions = $filter_html->getHTMLRestrictions(); + $allowed = $restrictions['allowed']; + + // Require `` HTML tag if filter_html is enabled. + if (!isset($allowed['drupal-media'])) { + $form_state->setError($form['filters']['settings']['filter_html']['allowed_html'], t('The %media-embed-filter-label filter requires <drupal-media> among the allowed HTML tags.', [ + '%media-embed-filter-label' => $get_filter_label('media_embed'), + ])); + } + else { + $required_attributes = [ + 'data-entity-type', + 'data-entity-uuid', + ]; + + // If there are no attributes, the allowed item is set to FALSE, + // otherwise, it is set to an array. + if ($allowed['drupal-media'] === FALSE) { + $missing_attributes = $required_attributes; + } + else { + $missing_attributes = array_diff($required_attributes, array_keys($allowed['drupal-media'])); + } + + if ($missing_attributes) { + $form_state->setError($form['filters']['settings']['filter_html']['allowed_html'], t('The <drupal-media> tag in the allowed HTML tags is missing the following attributes: %list.', [ + '%list' => implode(', ', $missing_attributes), + ])); + } + } + } + + $filters = $form_state->getValue('filters'); + + // The "media_embed" filter must run after "filter_align", "filter_caption", + // and "filter_html_image_secure". + $precedents = [ + 'filter_align', + 'filter_caption', + 'filter_html_image_secure', + ]; + + $error_filters = []; + foreach ($precedents as $filter_name) { + // A filter that should run before media embed filter. + $precedent = $filters[$filter_name]; + + if (empty($precedent['status']) || !isset($precedent['weight'])) { + continue; + } + + if ($precedent['weight'] >= $filters['media_embed']['weight']) { + $error_filters[$filter_name] = $get_filter_label($filter_name); + } + } + + if (!empty($error_filters)) { + $error_message = \Drupal::translation()->formatPlural( + count($error_filters), + 'The %media-embed-filter-label filter needs to be placed after the %filter filter.', + 'The %media-embed-filter-label filter needs to be placed after the following filters: %filters.', + [ + '%media-embed-filter-label' => $get_filter_label('media_embed'), + '%filter' => reset($error_filters), + '%filters' => implode(', ', $error_filters), + ] + ); + + $form_state->setErrorByName('filters', $error_message); + } +} diff --git a/core/modules/media/src/Plugin/Filter/MediaEmbed.php b/core/modules/media/src/Plugin/Filter/MediaEmbed.php new file mode 100644 index 000000000000..895cad9edf0a --- /dev/null +++ b/core/modules/media/src/Plugin/Filter/MediaEmbed.php @@ -0,0 +1,443 @@ +entityRepository = $entity_repository; + $this->entityTypeManager = $entity_type_manager; + $this->entityDisplayRepository = $entity_display_repository; + $this->renderer = $renderer; + $this->loggerFactory = $logger_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity.repository'), + $container->get('entity_type.manager'), + $container->get('entity_display.repository'), + $container->get('renderer'), + $container->get('logger.factory') + ); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $form['default_view_mode'] = [ + '#type' => 'select', + '#options' => $this->entityDisplayRepository->getViewModeOptions('media'), + '#title' => $this->t('Default view mode'), + '#default_value' => $this->settings['default_view_mode'], + '#description' => $this->t('The view mode that embedded media should be displayed in by default. This can be overridden by using the data-view-mode attribute.'), + ]; + return $form; + } + + /** + * Builds the render array for the given media entity in the given langcode. + * + * @param \Drupal\media\MediaInterface $media + * A media entity to render. + * @param string $view_mode + * The view mode to render it in. + * @param string $langcode + * Language code in which the media entity should be rendered. + * + * @return array + * A render array. + */ + protected function renderMedia(MediaInterface $media, $view_mode, $langcode) { + // Due to render caching and delayed calls, filtering happens later + // in the rendering process through a '#pre_render' callback, so we + // need to generate a counter for the media entity that is being embedded. + // @see \Drupal\filter\Element\ProcessedText::preRenderText() + $recursive_render_id = $media->uuid(); + if (isset(static::$recursiveRenderDepth[$recursive_render_id])) { + static::$recursiveRenderDepth[$recursive_render_id]++; + } + else { + static::$recursiveRenderDepth[$recursive_render_id] = 1; + } + // Protect ourselves from recursive rendering: return an empty render array. + if (static::$recursiveRenderDepth[$recursive_render_id] > EntityReferenceEntityFormatter::RECURSIVE_RENDER_LIMIT) { + $this->loggerFactory->get('media')->error('During rendering of embedded media: recursive rendering detected for %entity_id. Aborting rendering.', [ + '%entity_id' => $media->id(), + ]); + return []; + } + + $build = $this->entityTypeManager + ->getViewBuilder('media') + ->view($media, $view_mode, $langcode); + + // Allows other modules to treat embedded media items differently. + // @see quickedit_entity_view_alter() + $build['#embed'] = TRUE; + + // There are a few concerns when rendering an embedded media entity: + // - entity access checking happens not during rendering but during routing, + // and therefore we have to do it explicitly here for the embedded entity; + $build['#access'] = $media->access('view', NULL, TRUE); + // - caching an embedded media entity separately is unnecessary; the host + // entity is already render cached; + unset($build['#cache']['keys']); + // - Contextual Links do not make sense for embedded entities; we only allow + // the host entity to be contextually managed; + $build['#pre_render'][] = static::class . '::disableContextualLinks'; + // - default styling may break captioned media embeds; attach asset library + // to ensure captions behave as intended. Do not set this at the root + // level of the render array, otherwise it will be attached always, + // instead of only when #access allows this media to be viewed and hence + // only when media is actually rendered. + $build[':media_embed']['#attached']['library'][] = 'media/filter.caption'; + + return $build; + } + + /** + * Builds the render array for a missing media entity. + * + * @return array + * A render array. + */ + protected function renderMissingMedia() { + return [ + '#theme' => 'image', + '#uri' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')), + '#width' => 180, + '#height' => 180, + '#alt' => $this->t('Missing media.'), + '#title' => $this->t('Missing media.'), + ]; + } + + /** + * {@inheritdoc} + */ + public function process($text, $langcode) { + $result = new FilterProcessResult($text); + + if (stristr($text, 'query('//drupal-media[@data-entity-type="media" and normalize-space(@data-entity-uuid)!=""]') as $node) { + /** @var \DOMElement $node */ + $uuid = $node->getAttribute('data-entity-uuid'); + $view_mode_id = $node->getAttribute('data-view-mode') ?: $this->settings['default_view_mode']; + + // Delete the consumed attributes. + $node->removeAttribute('data-entity-type'); + $node->removeAttribute('data-entity-uuid'); + $node->removeAttribute('data-view-mode'); + + $media = $this->entityRepository->loadEntityByUuid('media', $uuid); + assert($media === NULL || $media instanceof MediaInterface); + if (!$media) { + $this->loggerFactory->get('media')->error('During rendering of embedded media: the media item with UUID "@uuid" does not exist.', ['@uuid' => $uuid]); + } + else { + $media = $this->entityRepository->getTranslationFromContext($media, $langcode); + $media = clone $media; + $this->applyPerEmbedMediaOverrides($node, $media); + } + + $view_mode = $this->entityRepository->loadEntityByConfigTarget('entity_view_mode', "media.$view_mode_id"); + if (!$view_mode) { + $this->loggerFactory->get('media')->error('During rendering of embedded media: the view mode "@view-mode-id" does not exist.', ['@view-mode-id' => $view_mode_id]); + } + + $build = $media && $view_mode + ? $this->renderMedia($media, $view_mode_id, $langcode) + : $this->renderMissingMedia(); + + // Any attributes not consumed by the filter should be carried over to the + // rendered embedded entity. For example, `data-align` and `data-caption` + // should be carried over, so that even when embedded media goes missing, + // at least the caption and visual structure won't get lost. + foreach ($node->attributes as $attribute) { + $build['#attributes'][$attribute->nodeName] = $attribute->nodeValue; + } + + $this->renderIntoDomNode($build, $node, $result); + } + + $result->setProcessedText(Html::serialize($dom)); + + return $result; + } + + /** + * {@inheritdoc} + */ + public function tips($long = FALSE) { + if ($long) { + return $this->t(' +

You can embed media items:

+ '); + } + else { + return $this->t('You can embed media items (using the <drupal-media> tag).'); + } + } + + /** + * Renders the given render array into the given DOM node. + * + * @param array $build + * The render array to render in isolation + * @param \DOMNode $node + * The DOM node to render into. + * @param \Drupal\filter\FilterProcessResult $result + * The accumulated result of filter processing, updated with the metadata + * bubbled during rendering. + */ + protected function renderIntoDomNode(array $build, \DOMNode $node, FilterProcessResult &$result) { + // We need to render the embedded entity: + // - without replacing placeholders, so that the placeholders are + // only replaced at the last possible moment. Hence we cannot use + // either renderPlain() or renderRoot(), so we must use render(). + // - without bubbling beyond this filter, because filters must + // ensure that the bubbleable metadata for the changes they make + // when filtering text makes it onto the FilterProcessResult + // object that they return ($result). To prevent that bubbling, we + // must wrap the call to render() in a render context. + $markup = $this->renderer->executeInRenderContext(new RenderContext(), function () use (&$build) { + return $this->renderer->render($build); + }); + $result = $result->merge(BubbleableMetadata::createFromRenderArray($build)); + static::replaceNodeContent($node, $markup); + } + + /** + * Replaces the contents of a DOMNode. + * + * @param \DOMNode $node + * A DOMNode object. + * @param string $content + * The text or HTML that will replace the contents of $node. + */ + protected static function replaceNodeContent(\DOMNode &$node, $content) { + if (strlen($content)) { + // Load the content into a new DOMDocument and retrieve the DOM nodes. + $replacement_nodes = Html::load($content)->getElementsByTagName('body') + ->item(0) + ->childNodes; + } + else { + $replacement_nodes = [$node->ownerDocument->createTextNode('')]; + } + + foreach ($replacement_nodes as $replacement_node) { + // Import the replacement node from the new DOMDocument into the original + // one, importing also the child nodes of the replacement node. + $replacement_node = $node->ownerDocument->importNode($replacement_node, TRUE); + $node->parentNode->insertBefore($replacement_node, $node); + } + $node->parentNode->removeChild($node); + } + + /** + * Disables Contextual Links for the embedded media by removing its property. + * + * @param array $build + * The render array for the embedded media. + * + * @return array + * The updated render array. + * + * @see \Drupal\Core\Entity\EntityViewBuilder::addContextualLinks() + */ + public static function disableContextualLinks(array $build) { + unset($build['#contextual_links']); + return $build; + } + + /** + * Applies attribute-based per-media embed overrides of media information. + * + * Currently, this only supports overriding an image media source's `alt` and + * `title`. Support for more overrides may be added in the future. + * + * @param \DOMElement $node + * The HTML tag whose attributes may contain overrides, and if such + * attributes are applied, they will be considered consumed and will + * therefore be removed from the HTML. + * @param \Drupal\media\MediaInterface $media + * The media entity to apply attribute-based overrides to, if any. + * + * @see \Drupal\media\Plugin\media\Source\Image + */ + protected function applyPerEmbedMediaOverrides(\DOMElement $node, MediaInterface $media) { + if ($image_field = $this->getMediaImageSourceField($media)) { + $settings = $media->{$image_field}->getItemDefinition()->getSettings(); + + if (!empty($settings['alt_field']) && $node->hasAttribute('alt')) { + $media->{$image_field}->alt = $node->getAttribute('alt'); + // All media entities have a thumbnail. In the case of image media, it + // is conceivable that a particular view mode chooses to display the + // thumbnail instead of the image field itself since the thumbnail + // simply shows a smaller version of the actual media. So we must update + // its `alt` too. Because its `alt` already is inherited from the image + // field's `alt` at entity save time. + // @see \Drupal\media\Plugin\media\Source\Image::getMetadata() + $media->thumbnail->alt = $node->getAttribute('alt'); + // Delete the consumed attribute. + $node->removeAttribute('alt'); + } + + if (!empty($settings['title_field']) && $node->hasAttribute('title')) { + // See above, the explanations for `alt` also apply to `title`. + $media->{$image_field}->title = $node->getAttribute('title'); + $media->thumbnail->title = $node->getAttribute('title'); + // Delete the consumed attribute. + $node->removeAttribute('title'); + } + } + } + + /** + * Get image field from source config. + * + * @param \Drupal\media\MediaInterface $media + * A media entity. + * + * @return string|null + * String of image field name. + */ + protected function getMediaImageSourceField(MediaInterface $media) { + $field_definition = $media->getSource() + ->getSourceFieldDefinition($media->bundle->entity); + $item_class = $field_definition->getItemDefinition()->getClass(); + if ($item_class == ImageItem::class || is_subclass_of($item_class, ImageItem::class)) { + return $field_definition->getName(); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public static function trustedCallbacks() { + return ['disableContextualLinks']; + } + +} diff --git a/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml b/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml new file mode 100644 index 000000000000..5c4c7838d6d3 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml @@ -0,0 +1,8 @@ +name: Media Filter test +description: 'Provides functionality to test the Media Embed filter.' +type: module +package: Testing +version: VERSION +core: 8.x +dependencies: + - drupal:media diff --git a/core/modules/media/tests/modules/media_test_filter/media_test_filter.module b/core/modules/media/tests/modules/media_test_filter/media_test_filter.module new file mode 100644 index 000000000000..b68c58b5b50e --- /dev/null +++ b/core/modules/media/tests/modules/media_test_filter/media_test_filter.module @@ -0,0 +1,25 @@ +addCacheTags(['_media_test_filter_access:' . $entity->getEntityTypeId() . ':' . $entity->id()]); +} + +/** + * Implements hook_entity_view_alter(). + */ +function media_test_filter_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + $build['#attributes']['data-media-embed-test-view-mode'] = $display->getMode(); +} diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiTest.php new file mode 100644 index 000000000000..e3eff54025fb --- /dev/null +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiTest.php @@ -0,0 +1,257 @@ + 'media_embed_test', + 'name' => 'Test format', + 'filters' => [], + ]); + $format->save(); + + $this->drupalLogin($this->drupalCreateUser([ + 'administer filters', + $format->getPermissionName(), + ])); + } + + /** + * @covers ::media_form_filter_format_add_form_alter + * @covers ::media_filter_format_edit_form_validate + * @dataProvider providerTestValidations + */ + public function testValidationWhenAdding($filter_html_status, $filter_align_status, $filter_caption_status, $filter_html_image_secure_status, $media_embed, $allowed_html, $expected_error_message) { + $this->drupalGet('admin/config/content/formats/add'); + + // Enable the `filter_html` and `media_embed` filters. + $page = $this->getSession()->getPage(); + $page->fillField('name', 'Another test format'); + $this->showHiddenFields(); + $page->findField('format')->setValue('another_media_embed_test'); + if ($filter_html_status) { + $page->checkField('filters[filter_html][status]'); + } + if ($filter_align_status) { + $page->checkField('filters[filter_align][status]'); + } + if ($filter_caption_status) { + $page->checkField('filters[filter_caption][status]'); + } + if ($filter_html_image_secure_status) { + $page->checkField('filters[filter_html_image_secure][status]'); + } + if ($media_embed === TRUE || is_numeric($media_embed)) { + $page->checkField('filters[media_embed][status]'); + // Set a non-default weight. + if (is_numeric($media_embed)) { + $this->click('.tabledrag-toggle-weight'); + $page->selectFieldOption('filters[media_embed][weight]', $media_embed); + } + } + if (!empty($allowed_html)) { + $page->clickLink('Limit allowed HTML tags and correct faulty HTML'); + $page->fillField('filters[filter_html][settings][allowed_html]', $allowed_html); + } + $page->pressButton('Save configuration'); + + if ($expected_error_message) { + $this->assertSession()->pageTextNotContains('Added text format Another test format.'); + $this->assertSession()->pageTextContains($expected_error_message); + } + else { + $this->assertSession()->pageTextContains('Added text format Another test format.'); + } + } + + /** + * @covers ::media_form_filter_format_edit_form_alter + * @covers ::media_filter_format_edit_form_validate + * @dataProvider providerTestValidations + */ + public function testValidationWhenEditing($filter_html_status, $filter_align_status, $filter_caption_status, $filter_html_image_secure_status, $media_embed, $allowed_html, $expected_error_message) { + $this->drupalGet('admin/config/content/formats/manage/media_embed_test'); + + // Enable the `filter_html` and `media_embed` filters. + $page = $this->getSession()->getPage(); + if ($filter_html_status) { + $page->checkField('filters[filter_html][status]'); + } + if ($filter_align_status) { + $page->checkField('filters[filter_align][status]'); + } + if ($filter_caption_status) { + $page->checkField('filters[filter_caption][status]'); + } + if ($filter_html_image_secure_status) { + $page->checkField('filters[filter_html_image_secure][status]'); + } + if ($media_embed === TRUE || is_numeric($media_embed)) { + $page->checkField('filters[media_embed][status]'); + // Set a non-default weight. + if (is_numeric($media_embed)) { + $this->click('.tabledrag-toggle-weight'); + $page->selectFieldOption('filters[media_embed][weight]', $media_embed); + } + } + if (!empty($allowed_html)) { + $page->clickLink('Limit allowed HTML tags and correct faulty HTML'); + $page->fillField('filters[filter_html][settings][allowed_html]', $allowed_html); + } + $page->pressButton('Save configuration'); + + if ($expected_error_message) { + $this->assertSession()->pageTextNotContains('The text format Test format has been updated.'); + $this->assertSession()->pageTextContains($expected_error_message); + } + else { + $this->assertSession()->pageTextContains('The text format Test format has been updated.'); + } + } + + /** + * Data provider for testValidationWhenAdding() and + * testValidationWhenEditing(). + */ + public function providerTestValidations() { + return [ + 'Tests that no filter_html occurs when filter_html not enabled.' => [ + 'filters[filter_html][status]' => FALSE, + 'filters[filter_align][status]' => FALSE, + 'filters[filter_caption][status]' => FALSE, + 'filters[filter_html_image_secure][status]' => FALSE, + 'media_embed' => TRUE, + 'allowed_html' => FALSE, + 'expected_error_message' => FALSE, + ], + 'Tests validation when both filter_html and media_embed are disabled.' => [ + 'filters[filter_html][status]' => FALSE, + 'filters[filter_align][status]' => FALSE, + 'filters[filter_caption][status]' => FALSE, + 'filters[filter_html_image_secure][status]' => FALSE, + 'media_embed' => FALSE, + 'allowed_html' => FALSE, + 'expected_error_message' => FALSE, + ], + 'Tests validation when media_embed filter not enabled and filter_html is enabled.' => [ + 'filters[filter_html][status]' => TRUE, + 'filters[filter_align][status]' => FALSE, + 'filters[filter_caption][status]' => FALSE, + 'filters[filter_html_image_secure][status]' => FALSE, + 'media_embed' => FALSE, + 'allowed_html' => 'default', + 'expected_error_message' => FALSE, + ], + 'Tests validation when drupal-media element has no attributes.' => [ + 'filters[filter_html][status]' => TRUE, + 'filters[filter_align][status]' => FALSE, + 'filters[filter_caption][status]' => FALSE, + 'filters[filter_html_image_secure][status]' => FALSE, + 'media_embed' => TRUE, + 'allowed_html' => "