diff --git a/core/modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css b/core/modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css index 9826afc017..7ef64b3df3 100644 --- a/core/modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css +++ b/core/modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css @@ -20,3 +20,13 @@ drupal-media { .cke_widget_drupalmedia.align-center { text-align: center; } + +/** + * Fix positioning without delete button. Overrides + * core/modules/media_library/css/media_library.theme.css. + * Can be removed with this issue: + * @see https://www.drupal.org/project/drupal/issues/3074859 + */ +drupal-media .media-library-item__edit { + right: 10px; +} diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js index 81f5561fab..d65d04e578 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js @@ -102,7 +102,7 @@ beforeInit(editor) { // Configure CKEditor DTD for custom drupal-media element. // @see https://www.drupal.org/node/2448449#comment-9717735 - const dtd = CKEDITOR.dtd; + const { dtd } = CKEDITOR; // Allow text within the drupal-media tag. dtd['drupal-media'] = { '#': 1 }; // Register drupal-media element as an allowed child in each tag that can @@ -131,7 +131,7 @@ }, upcast(element, data) { - const attributes = element.attributes; + const { attributes } = element; // This matches the behavior of the corresponding server-side text filter plugin. if ( element.name !== 'drupal-media' || @@ -160,12 +160,27 @@ }, data(event) { + // Only run during changes. + if (this.oldData) { + // In the CKEditor Widget, we use `hasCaption` to track whether + // the embedded media has a caption or not. The filter on the other + // hand simply uses an empty or non-existing `data-caption` + // attribute. So some conversion work is necessary to allow an + // empty caption with placeholder text. + if (!this.data.hasCaption && this.oldData.hasCaption) { + delete this.data.attributes['data-caption']; + } else if (this.data.hasCaption && !this.oldData.hasCaption) { + this.data.attributes['data-caption'] = ' '; + } + } + if (this._previewNeedsServerSideUpdate()) { editor.fire('lockSnapshot'); this._tearDownDynamicEditables(); this._loadPreview(widget => { widget._setUpDynamicEditables(); + widget._setUpButtons(); editor.fire('unlockSnapshot'); }); } @@ -179,6 +194,13 @@ this.element .getParent() .addClass(`align-${this.data.attributes['data-align']}`); + } else { + const classes = this.element.getParent().$.classList; + for (let i = 0; i < classes.length; i++) { + if (classes[i].indexOf('align-') === 0) { + this.element.getParent().removeClass(classes[i]); + } + } } // Track the previous state to allow checking if preview needs @@ -224,7 +246,111 @@ childList: true, subtree: true, }); + // Some browsers will add a
tag to a newly created DOM + // element with no content. Remove this
if it is the only + // thing in the caption. Our placeholder support requires the + // element be entirely empty. See filter-caption.css. + // @see core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.es6.js + if ( + captionEditable.$.childNodes.length === 1 && + captionEditable.$.childNodes.item(0).nodeName === 'BR' + ) { + captionEditable.$.removeChild( + captionEditable.$.childNodes.item(0), + ); + } + } + }, + + /** + * Injects HTML for buttons into the preview that was just loaded. + */ + _setUpButtons() { + // No buttons for missing media. + if (this.element.findOne('.media-embed-error')) { + return; } + + /** + * Determine if a node is an element node. + * + * @param {CKEDITOR.dom.node} n + * A DOM node to evaluate. + * + * @return {bool} + * Returns true if node is an element node and not a non-element + * node (such as NODE_TEXT, NODE_COMMENT, NODE_DOCUMENT or + * NODE_DOCUMENT_FRAGMENT). + * + * @see https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR.html#property-NODE_ELEMENT + */ + const isElementNode = function(n) { + return n.type === CKEDITOR.NODE_ELEMENT; + }; + + // Find the actual embedded media in the DOM. + const embeddedMediaContainer = this.data.hasCaption + ? this.element.findOne('figure') + : this.element; + let embeddedMedia = embeddedMediaContainer.getFirst(isElementNode); + // If there is a link, the top-level element is the `a tag, + // and the embedded media will be within the `a` tag. + if (this.data.link) { + embeddedMedia = embeddedMedia.getFirst(isElementNode); + } + // To allow the edit button to be absolutely positioned, the parent + // element must be positioned relative. + embeddedMedia.setStyle('position', 'relative'); + + const editButton = CKEDITOR.dom.element.createFromHtml( + ``, + ); + embeddedMedia.getFirst().insertBeforeMe(editButton); + + // Make the edit button do things. + const widget = this; + this.element + .findOne('.media-library-item__edit') + .on('click', event => { + const saveCallback = function(values) { + event.cancel(); + editor.fire('saveSnapshot'); + if (values.hasOwnProperty('attributes')) { + CKEDITOR.tools.extend( + values.attributes, + widget.data.attributes, + ); + // Allow the dialog to delete attributes by setting them + // to `false` or `none`. For example: `alt`. + Object.keys(values.attributes).forEach(prop => { + if ( + values.attributes[prop] === false || + (prop === 'data-align' && + values.attributes[prop] === 'none') + ) { + delete values.attributes[prop]; + } + }); + } + widget.setData({ + attributes: values.attributes, + hasCaption: !!values.hasCaption, + }); + editor.fire('saveSnapshot'); + }; + + Drupal.ckeditor.openDialog( + editor, + Drupal.url( + `editor/dialog/media/${editor.config.drupal.format}`, + ), + widget.data, + saveCallback, + {}, + ); + }); }, _tearDownDynamicEditables() { @@ -237,7 +363,8 @@ /** * Determines if the preview needs to be re-rendered by the server. * - * @returns {boolean} + * @return {boolean} + * Returns true if the data hashes differ. */ _previewNeedsServerSideUpdate() { // When the widget is first loading, it of course needs to still get a preview! diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.js b/core/modules/media/js/plugins/drupalmedia/plugin.js index 76459293b4..b1012bcb71 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.js @@ -126,12 +126,21 @@ this._tearDownDynamicEditables(); }, data: function data(event) { + if (this.oldData) { + if (!this.data.hasCaption && this.oldData.hasCaption) { + delete this.data.attributes['data-caption']; + } else if (this.data.hasCaption && !this.oldData.hasCaption) { + this.data.attributes['data-caption'] = ' '; + } + } + if (this._previewNeedsServerSideUpdate()) { editor.fire('lockSnapshot'); this._tearDownDynamicEditables(); this._loadPreview(function (widget) { widget._setUpDynamicEditables(); + widget._setUpButtons(); editor.fire('unlockSnapshot'); }); } @@ -140,6 +149,13 @@ if (this.data.attributes.hasOwnProperty('data-align')) { this.element.getParent().addClass('align-' + this.data.attributes['data-align']); + } else { + var classes = this.element.getParent().$.classList; + for (var i = 0; i < classes.length; i++) { + if (classes[i].indexOf('align-') === 0) { + this.element.getParent().removeClass(classes[i]); + } + } } this.oldData = CKEDITOR.tools.clone(this.data); @@ -172,7 +188,56 @@ childList: true, subtree: true }); + + if (captionEditable.$.childNodes.length === 1 && captionEditable.$.childNodes.item(0).nodeName === 'BR') { + captionEditable.$.removeChild(captionEditable.$.childNodes.item(0)); + } + } + }, + _setUpButtons: function _setUpButtons() { + if (this.element.findOne('.media-embed-error')) { + return; + } + + var isElementNode = function isElementNode(n) { + return n.type === CKEDITOR.NODE_ELEMENT; + }; + + var embeddedMediaContainer = this.data.hasCaption ? this.element.findOne('figure') : this.element; + var embeddedMedia = embeddedMediaContainer.getFirst(isElementNode); + + if (this.data.link) { + embeddedMedia = embeddedMedia.getFirst(isElementNode); } + + embeddedMedia.setStyle('position', 'relative'); + + var editButton = CKEDITOR.dom.element.createFromHtml(''); + embeddedMedia.getFirst().insertBeforeMe(editButton); + + var widget = this; + this.element.findOne('.media-library-item__edit').on('click', function (event) { + var saveCallback = function saveCallback(values) { + event.cancel(); + editor.fire('saveSnapshot'); + if (values.hasOwnProperty('attributes')) { + CKEDITOR.tools.extend(values.attributes, widget.data.attributes); + + Object.keys(values.attributes).forEach(function (prop) { + if (values.attributes[prop] === false || prop === 'data-align' && values.attributes[prop] === 'none') { + delete values.attributes[prop]; + } + }); + } + widget.setData({ + attributes: values.attributes, + hasCaption: !!values.hasCaption + }); + editor.fire('saveSnapshot'); + }; + + Drupal.ckeditor.openDialog(editor, Drupal.url('editor/dialog/media/' + editor.config.drupal.format), widget.data, saveCallback, {}); + }); }, _tearDownDynamicEditables: function _tearDownDynamicEditables() { if (this.captionObserver) { diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 19dadf9e01..918071361c 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -48,3 +48,12 @@ media.filter.preview: requirements: _entity_access: 'filter_format.use' _custom_access: '\Drupal\media\Controller\MediaFilterController::formatUsesMediaEmbedFilter' + +editor.media_dialog: + path: '/editor/dialog/media/{editor}' + defaults: + _form: '\Drupal\media\Form\EditorMediaDialog' + _title: 'Edit media' + methods: [POST] + requirements: + _entity_access: 'editor.use' diff --git a/core/modules/media/src/Form/EditorMediaDialog.php b/core/modules/media/src/Form/EditorMediaDialog.php new file mode 100644 index 0000000000..151945e42c --- /dev/null +++ b/core/modules/media/src/Form/EditorMediaDialog.php @@ -0,0 +1,259 @@ +entityRepository = $entity_repository; + $this->languageManager = $language_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.repository'), + $container->get('language_manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'editor_media_dialog'; + } + + /** + * {@inheritdoc} + * + * @param \Drupal\editor\Entity\Editor $editor + * The text editor to which this dialog corresponds. + */ + public function buildForm(array $form, FormStateInterface $form_state, EditorInterface $editor = NULL) { + // This form is special, in that the default values do not come from the + // server side, but from the client side, from a text editor. We must cache + // this data in form state, because when the form is rebuilt, we will be + // receiving values from the form, instead of the values from the text + // editor. If we don't cache it, this data will be lost. + if (isset($form_state->getUserInput()['editor_object'])) { + $editor_object = $form_state->getUserInput()['editor_object']; + // By convention, the data that the text editor sends to any dialog is in + // the 'editor_object' key. + $media_embed_element = $editor_object['attributes']; + $form_state->set('media_embed_element', $media_embed_element); + $has_caption = $editor_object['hasCaption']; + $form_state->set('hasCaption', $has_caption); + $form_state->setCached(TRUE); + } + else { + // Retrieve the user input from form state. + $media_embed_element = $form_state->get('media_embed_element'); + $has_caption = $form_state->get('hasCaption'); + } + + $form['#tree'] = TRUE; + $form['#attached']['library'][] = 'editor/drupal.editor.dialog'; + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + + $filters = $editor->getFilterFormat()->filters(); + $filter_html = $filters->get('filter_html'); + $filter_align = $filters->get('filter_align'); + $filter_caption = $filters->get('filter_caption'); + + $allowed_attributes = []; + if ($filter_html->status) { + $restrictions = $filter_html->getHTMLRestrictions(); + $allowed_attributes = $restrictions['allowed']['drupal-media']; + } + + $media = $this->entityRepository->loadEntityByUuid('media', $media_embed_element['data-entity-uuid']); + $langcode = $this->languageManager + ->getCurrentLanguage() + ->getId(); + if ($media->hasTranslation($langcode)) { + $media = $media->getTranslation($langcode); + } + if ($image_field = $this->getMediaImageSourceField($media)) { + $settings = $media->{$image_field}->getItemDefinition()->getSettings(); + $alt = isset($media_embed_element['alt']) ? $media_embed_element['alt'] : NULL; + $form['alt'] = [ + '#type' => 'textfield', + '#title' => $this->t('Alternate text'), + '#default_value' => $alt, + '#description' => $this->t('Short description of the image used by screen readers and displayed when the image is not loaded. This is important for accessibility.'), + '#required_error' => $this->t('Alternative text is required.
(Only in rare cases should this be left empty. To create empty alternative text, enter "" — two double quotes without any content).'), + '#maxlength' => 2048, + '#placeholder' => $media->{$image_field}->alt, + '#parents' => ['attributes', 'alt'], + '#access' => !empty($settings['alt_field']) && ($filter_html->status === FALSE || !empty($allowed_attributes['alt'])), + ]; + } + + // When Drupal core's filter_align is being used, the text editor offers the + // ability to change the alignment. + $form['align'] = [ + '#title' => $this->t('Align'), + '#type' => 'radios', + '#options' => [ + 'none' => $this->t('None'), + 'left' => $this->t('Left'), + 'center' => $this->t('Center'), + 'right' => $this->t('Right'), + ], + '#default_value' => empty($media_embed_element['data-align']) ? 'none' : $media_embed_element['data-align'], + '#attributes' => ['class' => ['container-inline']], + '#parents' => ['attributes', 'data-align'], + '#access' => $filter_align->status && ($filter_html->status === FALSE || !empty($allowed_attributes['data-align'])), + ]; + + // When Drupal core's filter_caption is being used, the text editor offers + // the ability to in-place edit the media's caption: show a toggle. + $form['caption'] = [ + '#title' => $this->t('Caption'), + '#type' => 'checkbox', + '#default_value' => $has_caption === 'true', + '#parents' => ['hasCaption'], + '#access' => $filter_caption->status && ($filter_html->status === FALSE || !empty($allowed_attributes['data-caption'])), + ]; + + if ($form['alt']['#access'] === FALSE && $form['align']['#access'] === FALSE && $form['caption']['#access'] === FALSE) { + $format = $editor->getFilterFormat(); + $form['no_access_notice'] = [ + '#markup' => $this->t('There is nothing to override for this media.'), + ]; + if ($this->currentUser()->hasPermission('administer filters')) { + $text_format_edit_link = $format + ->toLink($format->label(), 'edit-form') + ->toString(); + $form['no_access_notice'] = [ + '#markup' => $this->t('There is nothing to override for this media. Edit the text format %format to modify the attributes that can be overridden.', ['%format' => $text_format_edit_link]), + ]; + } + } + + $form['actions'] = [ + '#type' => 'actions', + ]; + $form['actions']['save_modal'] = [ + '#type' => 'submit', + '#value' => $this->t('Save'), + // No regular submit-handler. This form only works via JavaScript. + '#submit' => [], + '#ajax' => [ + 'callback' => '::submitForm', + 'event' => 'click', + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $response = new AjaxResponse(); + + // When the `alt` attribute is set to two double quotes, transform it to the + // empty string: two double quotes signify "empty alt attribute". See above. + if (trim($form_state->getValue(['attributes', 'alt'], '')) === '""') { + $form_state->setValue(['attributes', 'alt'], '""'); + } + + // The `alt` and `title` attributes are optional: if they're not set, their + // default values simply will not be overridden. It's important to set + // these to FALSE instead of unsetting the value so that We explicitly + // inform the client side about the new value. + if ($form_state->hasValue(['attributes', 'alt']) && trim($form_state->getValue(['attributes', 'alt'])) === '') { + $form_state->setValue(['attributes', 'alt'], FALSE); + } + + if ($form_state->getErrors()) { + unset($form['#prefix'], $form['#suffix']); + $form['status_messages'] = [ + '#type' => 'status_messages', + '#weight' => -10, + ]; + $response->addCommand(new HtmlCommand('#editor-media-dialog-form', $form)); + } + else { + // Only send back the relevant values. + $values = [ + 'hasCaption' => $form_state->getValue('hasCaption'), + 'attributes' => $form_state->getValue('attributes'), + ]; + $response->addCommand(new EditorDialogSave($values)); + $response->addCommand(new CloseModalDialogCommand()); + } + + return $response; + } + + /** + * Get image field from source config. + * + * @param \Drupal\media\MediaInterface $media + * Embedded media. + * + * @return string|null + * "The name of the image source field configured for the media item, or + * NULL if the source field is not an image field. + */ + protected function getMediaImageSourceField(MediaInterface $media) { + $field_definition = $media->getSource() + ->getSourceFieldDefinition($media->bundle->entity); + $item_class = $field_definition->getItemDefinition()->getClass(); + if (is_a($item_class, ImageItem::class, TRUE)) { + return $field_definition->getName(); + } + return NULL; + } + +} diff --git a/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php b/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php index 35717d5a76..120a848ef8 100644 --- a/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php +++ b/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php @@ -124,6 +124,8 @@ public function getCssFiles(Editor $editor) { $this->moduleExtensionList->getPath('media') . '/css/filter.media_embed.css', $this->moduleExtensionList->getPath('media') . '/css/plugins/drupalmedia/ckeditor.drupalmedia.css', $this->moduleExtensionList->getPath('system') . '/css/components/hidden.module.css', + // Add media_library.theme.css for edit button styling. + $this->moduleExtensionList->getPath('media_library') . '/css/media_library.theme.css', ]; } diff --git a/core/modules/media/src/Plugin/Filter/MediaEmbed.php b/core/modules/media/src/Plugin/Filter/MediaEmbed.php index 3145258cba..f035425bdb 100644 --- a/core/modules/media/src/Plugin/Filter/MediaEmbed.php +++ b/core/modules/media/src/Plugin/Filter/MediaEmbed.php @@ -26,7 +26,7 @@ * @Filter( * id = "media_embed", * title = @Translation("Embed media"), - * description = @Translation("Embeds media items using a custom HTML tag. If used in conjunction with the 'Align/Caption' filters, make sure this filter is configured to run after them."), + * description = @Translation("Embeds media items using a custom tag, <drupal-media>. If used in conjunction with the 'Align/Caption' filters, make sure this filter is configured to run after them. If you are using the HTML filter, be sure the data-align and/or data-caption attributes are allowed on the <drupal-media> tag. If you would like users to be able to override the alt text on image media, add the alt attribute as well to the <drupal-media> tag."), * type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE, * settings = { * "default_view_mode" = "full", @@ -414,6 +414,13 @@ protected function applyPerEmbedMediaOverrides(\DOMElement $node, MediaInterface $settings = $media->{$image_field}->getItemDefinition()->getSettings(); if (!empty($settings['alt_field']) && $node->hasAttribute('alt')) { + // Allow the display of the image without an alt tag in special cases. + // Since setting the value in the EditorMediaDialog to an empty string + // restores the default value, this allows special cases where the + // alt should not be set to the default value but should be empty. + if ($node->getAttribute('alt') === '""') { + $node->setAttribute('alt', NULL); + } $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 diff --git a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php index d0e7588697..a4371b8585 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php @@ -5,13 +5,18 @@ use Drupal\Component\Utility\Html; use Drupal\Core\Url; use Drupal\editor\Entity\Editor; +use Drupal\field\Entity\FieldConfig; use Drupal\file\Entity\File; use Drupal\filter\Entity\FilterFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\language\Entity\ContentLanguageSettings; use Drupal\media\Entity\Media; use Drupal\Tests\ckeditor\Traits\CKEditorTestTrait; use Drupal\Tests\media\Traits\MediaTypeCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; /** * @coversDefaultClass \Drupal\media\Plugin\CKEditorPlugin\DrupalMedia @@ -244,13 +249,42 @@ public function testPreviewUsesDefaultThemeAndIsClientCacheable() { * Tests caption editing in the CKEditor widget. */ public function testEditableCaption() { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); $this->drupalGet($this->host->toUrl('edit-form')); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + // Assert that figcaption element exists within the drupal-media element. + $this->assertNotEmpty($figcaption = $assert_session->waitForElement('css', 'figcaption')); + $this->assertSame('baz', $figcaption->getHtml()); + + // Test that disabling the caption in the metadata dialog removes it + // from the drupal-media element. + $page->pressButton('Edit media'); + $this->waitforMetadataDialog(); + $page->uncheckField('hasCaption'); + $this->submitDialog(); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertNotEmpty($drupal_media = $assert_session->waitForElementVisible('css', 'drupal-media', 2000)); - // Type in the widget's editable for the caption. + // Wait for element to update without figcaption. + $result = $page->waitFor(10, function () use ($drupal_media) { + return empty($drupal_media->find('css', 'figcaption')); + }); + // Will be true if no figcaption exists within the drupal-media element. + $this->assertTrue($result); + + // Test that enabling the caption in the metadata dialog adds an editable + // caption to the embedded media. + $page->pressButton('Edit media'); + $this->waitforMetadataDialog(); + $page->checkField('hasCaption'); + $this->submitDialog(); $this->getSession()->switchToIFrame('ckeditor'); - $assert_session = $this->assertSession(); + $this->assertNotEmpty($drupal_media = $assert_session->waitForElementVisible('css', 'drupal-media figcaption', 2000)); + + // Type in the widget's CKEDITOR.editable element for the caption. $this->assertNotEmpty($assert_session->waitForElement('css', 'figcaption')); $this->setCaption('Caught in a landslide! No escape from reality!'); $this->getSession()->switchToIFrame('ckeditor'); @@ -362,6 +396,320 @@ public function testEditableCaption() { $assert_session->elementContains('css', 'figcaption', 'Fin.'); } + /** + * Test the EditorMediaDialog's form elements' #access logic. + */ + public function testDialogAccess() { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + + // Enable `filter_html` without "alt", "data-align" or "data-caption" + // attributes added to the drupal-media tag. + $allowed_html = "
    1. "; + $filter_format = FilterFormat::load('test_format'); + $filter_format->setFilterConfig('filter_html', [ + 'status' => TRUE, + 'settings' => [ + 'allowed_html' => $allowed_html, + ], + ])->save(); + + // Test the validation of attributes in the dialog. If the alt, + // data-caption, and data-align attributes are not set on the drupal-media + // tag, the respective fields shouldn't display in the dialog. + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $assert_session->fieldNotExists('attributes[alt]'); + $assert_session->fieldNotExists('attributes[align]'); + $assert_session->fieldNotExists('hasCaption'); + $assert_session->pageTextContains('There is nothing to override for this media.'); + // The edit link for the format shouldn't appear unless the user has + // permission to edit the text format. + $assert_session->pageTextNotContains('Edit the text format Test format to modify the attributes that can be overridden.'); + $page->pressButton('Close'); + $this->getSession()->switchToIFrame('ckeditor'); + + // Now test the same thing with a user who has access to edit text formats. + // An extra message containing a link to edit the text format should + // appear. + $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), ['administer filters']); + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $assert_session->fieldNotExists('attributes[alt]'); + $assert_session->fieldNotExists('attributes[align]'); + $assert_session->fieldNotExists('hasCaption'); + $assert_session->pageTextContains('There is nothing to override for this media. Edit the text format Test format to modify the attributes that can be overridden.'); + $assert_session->linkExists('text format'); + $page->pressButton('Close'); + $this->getSession()->switchToIFrame('ckeditor'); + + // Now test that adding the attributes to the allowed HTML will allow + // the fields to display in the dialog. + $allowed_html = str_replace('', '', $allowed_html); + $filter_format->setFilterConfig('filter_html', [ + 'status' => TRUE, + 'settings' => [ + 'allowed_html' => $allowed_html, + ], + ])->save(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $assert_session->fieldExists('attributes[alt]'); + $assert_session->fieldExists('attributes[data-align]'); + $assert_session->fieldExists('hasCaption'); + $page->pressButton('Close'); + $this->getSession()->switchToIFrame('ckeditor'); + + // Test that setting the media image field to not display alt field also + // disables it in the dialog. + FieldConfig::loadByName('media', 'image', 'field_media_image') + ->setSetting('alt_field', FALSE) + ->save(); + // Clear the cached field configs. + $this->container->get('cache.discovery')->deleteAll(); + // Wait for preview. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $assert_session->fieldNotExists('attributes[alt]'); + $page->pressButton('Close'); + $this->getSession()->switchToIFrame('ckeditor'); + + // Test that enabling the alt field on the media image field restores + // the field in the dialog. + FieldConfig::loadByName('media', 'image', 'field_media_image') + ->setSetting('alt_field', TRUE) + ->save(); + // Clear the cached field configs. + $this->container->get('cache.discovery')->deleteAll(); + // Wait for preview. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $assert_session->fieldExists('attributes[alt]'); + $page->pressButton('Close'); + $this->getSession()->switchToIFrame('ckeditor'); + + // Test that setting disabling `filter_caption` and `filter_align` disables + // the respective fields in the dialog. + $filter_format + ->setFilterConfig('filter_caption', [ + 'status' => FALSE, + ])->setFilterConfig('filter_align', [ + 'status' => FALSE, + ])->save(); + // Wait for preview. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $assert_session->fieldNotExists('attributes[data-align]'); + $assert_session->fieldNotExists('hasCaption'); + // The alt field should be unaffected. + $assert_session->fieldExists('attributes[alt]'); + $page->pressButton('Close'); + $this->getSession()->switchToIFrame('ckeditor'); + + // Test that enabling the two filters restores the fields in the dialog. + $filter_format + ->setFilterConfig('filter_caption', [ + 'status' => TRUE, + ])->setFilterConfig('filter_align', [ + 'status' => TRUE, + ])->save(); + // Wait for preview. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $assert_session->fieldExists('attributes[data-align]'); + $assert_session->fieldExists('hasCaption'); + // The alt field should be unaffected. + $assert_session->fieldExists('attributes[alt]'); + } + + /** + * Tests the EditorMediaDialog can set the alt attribute. + */ + public function testAlt() { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + + // Test that the default alt attribute displays without an override. + $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "default alt")]')); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $alt = $page->findField('attributes[alt]'); + // Assert that the placeholder is set to the value of the media field's + // alt text. + $this->assertSame('default alt', $alt->getAttribute('placeholder')); + + // Fill in the alt field in the dialog. + $who_is_zartan = 'Zartan is the leader of the Dreadnoks.'; + $page->fillField('attributes[alt]', $who_is_zartan); + $this->submitDialog(); + $this->getSession()->switchToIFrame('ckeditor'); + + // Assert that the img within the media embed within the ckeditor contains + // the overridden alt text set in the dialog. + $this->assertNotEmpty($img = $assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $who_is_zartan . '")]')); + + // Reopen the dialog. + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + + // The alt field should now display the override instead of the default. + $assert_session->fieldValueEquals('attributes[alt]', $who_is_zartan); + + // Test the process against with a different alt text to make sure it works + // the second time around. + $cobra_commander_bio = 'The supreme leader of the terrorist organization Cobra'; + // Set the alt field to the new alt text. + $alt->setValue($cobra_commander_bio); + $this->submitDialog(); + $this->getSession()->switchToIFrame('ckeditor'); + // Assert that the img within the media embed within the ckeditor contains + // the overridden alt text set in the dialog. + $this->assertNotEmpty($img = $assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $cobra_commander_bio . '")]')); + + // Reopen the dialog. + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + // The alt field should now display the override instead of the default. + $alt = $page->findField('attributes[alt]'); + $this->assertSame($cobra_commander_bio, $alt->getValue()); + + // Test that setting alt value to two double quotes will signal to the + // MediaEmbed filter to unset the attribute on the media image field. + $alt->setValue('""'); + $this->submitDialog(); + $this->getSession()->switchToIFrame('ckeditor'); + $img = $assert_session->waitForElementVisible('xpath', '//img'); + // Wait for element to update, by checking for when the old alt text is + // removed. + $result = $page->waitFor(10, function () use ($img, $cobra_commander_bio) { + return ($img->getAttribute('alt') !== $cobra_commander_bio); + }); + $this->assertTrue($result); + // Verify that the two double quote empty alt indicator ('""') set in + // the dialog has successfully resulted in a media image field with the + // alt attribute present but without a value. + $this->assertTrue($img->hasAttribute('alt')); + $this->assertEmpty($img->getAttribute('alt')); + + // Test that setting alt to back to an empty string within the dialog will + // restore the default alt value saved in to the media image field of the + // media item. + $this->fillFieldInMetadataDialog('attributes[alt]', ""); + $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "default alt")]')); + } + + /** + * Test that dialog loads appropriate translation's alt text. + */ + public function testTranslationAlt() { + \Drupal::service('module_installer')->install(['language', 'content_translation']); + $this->rebuildContainer(); + ConfigurableLanguage::create(['id' => 'fr'])->save(); + ContentLanguageSettings::loadByEntityTypeBundle('media', 'image') + ->setDefaultLangcode('en') + ->setLanguageAlterable(TRUE) + ->save(); + $media = Media::create([ + 'bundle' => 'image', + 'name' => 'Screaming hairy armadillo', + 'field_media_image' => [ + [ + 'target_id' => 1, + 'alt' => 'default alt', + 'title' => 'default title', + ], + ], + ]); + $media->save(); + $media_fr = $media->addTranslation('fr'); + $media_fr->name = "Tatou poilu hurlant"; + $media_fr->field_media_image->setValue([ + [ + 'target_id' => '1', + 'alt' => "texte alternatif par défaut", + 'title' => "titre alternatif par défaut", + ], + ]); + $media_fr->save(); + + ContentLanguageSettings::loadByEntityTypeBundle('node', 'blog') + ->setDefaultLangcode('en') + ->setLanguageAlterable(TRUE) + ->save(); + + $host = $this->createNode([ + 'type' => 'blog', + 'title' => 'Animals with strange names', + 'body' => [ + 'value' => '', + 'format' => 'test_format', + ], + ]); + $host->save(); + + $translation = $host->addTranslation('fr'); + $translation->title = 'Animaux avec des noms étranges'; + $translation->body->value = $host->body->value; + $translation->body->format = $host->body->format; + $translation->body->lang = 'fr'; + $translation->save(); + + $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), ['translate any entity']); + + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + $this->drupalGet('/fr/node/' . $host->id() . '/edit'); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + + // Test that the default alt attribute displays without an override. + $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "texte alternatif par défaut")]')); + $page->pressButton('Edit media'); + $this->waitForMetadataDialog(); + $alt = $page->findField('attributes[alt]'); + // Assert that the placeholder is set to the value of the media field's + // alt text. + $this->assertSame('texte alternatif par défaut', $alt->getAttribute('placeholder')); + + // Fill in the alt field in the dialog. + $qui_est_zartan = 'Zartan est le chef des Dreadnoks.'; + $page->fillField('attributes[alt]', $qui_est_zartan); + $this->submitDialog(); + $this->getSession()->switchToIFrame('ckeditor'); + + // Assert that the img within the media embed within the ckeditor contains + // the overridden alt text set in the dialog. + $this->assertNotEmpty($img = $assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $qui_est_zartan . '")]')); + $this->getSession()->switchToIFrame(); + $page->pressButton('Save'); + $assert_session->elementExists('xpath', '//img[contains(@alt, "' . $qui_est_zartan . '")]'); + + } + /** * Tests linkability of the CKEditor widget. * @@ -611,34 +959,126 @@ public function previewAccessProvider() { } /** - * Tests that alignment is reflected onto the CKEditor Widget wrapper. + * Tests alignment integration. + * + * Tests that alignment is reflected onto the CKEditor Widget wrapper, that + * the EditorMediaDialog allows altering the alignment and that the changes + * are reflected on the widget and downcast drupal-media tag. */ - public function testAlignmentClasses() { + public function testAlignment() { + $assert_session = $this->assertSession(); + + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + // Assert that setting the data-align property in the dialog adds the + // `align-right', `align-left` or `align-center' class on the widget, + // caption figure and drupal-media tag. $alignments = [ 'right', 'left', 'center', ]; - $assert_session = $this->assertSession(); foreach ($alignments as $alignment) { - $this->host->body->value = ''; - $this->host->save(); - - // The upcasted CKEditor Widget's wrapper must get an `align-*` class. - $this->drupalGet($this->host->toUrl('edit-form')); - $this->waitForEditor(); + $this->fillFieldInMetadataDialog('attributes[data-align]', $alignment); + // Now verify the result. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media[data-align="' . $alignment . '"]', 2000)); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.caption-drupal-media.align-' . $alignment, 2000)); + + // Assert that the resultant downcast drupal-media tag has the proper + // `data-align` attribute. + $drupal_media = $this->getDrupalMediaFromSource(); + $this->assertSame($alignment, $drupal_media->getAttribute('data-align')); + + // Leave source mode. + $this->pressEditorButton('source'); + // Having entered source mode means we need to reassign an id to the + // CKEditor iframe. $this->assignNameToCkeditorIframe(); $this->getSession()->switchToIFrame('ckeditor'); - $wrapper = $assert_session->waitForElementVisible('css', '.cke_widget_drupalmedia', 2000); - $this->assertNotEmpty($wrapper); - $this->assertTrue($wrapper->hasClass('align-' . $alignment)); } + // Test that setting the "Align" field to "none" in the dialog will + // remove the attribute from the drupal-media element in the ckeditor. + $this->fillFieldInMetadataDialog('attributes[data-align]', 'none'); + + // Assert that neither the widget nor the caption figure have alignment + // classes. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.caption-drupal-media:not([class*="align-"])', 2000)); + $assert_session->elementExists('css', '.cke_widget_drupalmedia:not([class*="align-"])'); + // Assert that the resultant downcast drupal-media tag has no data-align + // attribute. + $drupal_media = $this->getDrupalMediaFromSource(); + $this->assertFalse($drupal_media->hasAttribute('data-align')); + } + + /** + * Waits for the form that allows editing metadata. + * + * @see /core/modules/media/src/Form/EditorMediaDialog.php + */ + protected function waitForMetadataDialog() { + $page = $this->getSession()->getPage(); + $this->getSession()->switchToIFrame(); + // Wait for the dialog to open. + $result = $page->waitFor(10, function () use ($page) { + $metadata_editor = $page->find('css', 'form.editor-media-dialog'); + return !empty($metadata_editor); + }); + $this->assertTrue($result); + } + + /** + * Fills in field with specified locator in EditorMediaDialog form. + * + * @param string $locator + * The input id, name or label. + * @param string $value + * The value to set on the field. + */ + protected function fillFieldInMetadataDialog($locator, $value) { + $page = $this->getSession()->getPage(); + // If not within the ckeditor iframe, switch to it. + if ($page->has('css', '#ckeditor')) { + $this->getSession()->switchToIFrame('ckeditor'); + } + // Wait for the drupal-media which holds the "Edit media" button which + // opens the dialog. + $this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', 'drupal-media', 2000)); + $page->pressButton('Edit media'); + $this->waitforMetadataDialog(); + $page->fillField($locator, $value); + $this->submitDialog(); + // Since ::waitforMetadataDialog() switches back to the main iframe, we'll + // need to switch back. + $this->getSession()->switchToIFrame('ckeditor'); + } + + /** + * Closes and submits the metadata dialog. + */ + protected function submitDialog() { + $this->assertNotEmpty($dialog_buttons = $this->assertSession()->elementExists('css', 'div.ui-dialog-buttonpane')); + $dialog_buttons->pressButton('Save'); + } + + /** + * Closes the metadata dialog. + */ + protected function closeDialog() { + $page = $this->getSession()->getPage(); + $page->pressButton('Close'); + $result = $page->waitFor(10, function () use ($page) { + $metadata_editor = $page->find('css', 'form.editor-media-dialog'); + return empty($metadata_editor); + }); + $this->assertTrue($result); } /** * Gets the transfer size of the last preview request. * * @return int + * The size of the bytes transferred. */ protected function getLastPreviewRequestTransferSize() { $this->getSession()->switchToIFrame(); @@ -745,8 +1185,6 @@ protected function closeContextMenu($instance_id = 'edit-body-0-value') { * * @param string $text * The title attribute of the link to click. - * - * @throws \Behat\Mink\Exception\ElementNotFoundException */ protected function clickPathLinkByTitleAttribute($text) { $this->getSession()->switchToIFrame(); @@ -754,4 +1192,20 @@ protected function clickPathLinkByTitleAttribute($text) { $this->assertSession()->elementExists('xpath', $selector)->click(); } + /** + * Get the drupal-media element from the ckeditor source. + * + * @return \DOMNode + * The drupal-media element. + */ + protected function getDrupalMediaFromSource() { + $this->pressEditorButton('source'); + $value = $this->assertSession() + ->elementExists('css', 'textarea.cke_source') + ->getValue(); + $dom = Html::load($value); + $xpath = new \DOMXPath($dom); + return $xpath->query('//drupal-media')[0]; + } + } diff --git a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php index 96d29bdc39..abe4442e12 100644 --- a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php +++ b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php @@ -214,11 +214,15 @@ public function testOverridesAltAndTitle($title_field_property_enabled, array $e 'alt' => 'alt 3', 'title' => 'title 3', ] + $base); + $input .= $this->createEmbedCode([ + 'alt' => '""', + 'title' => 'title 4', + ] + $base); $this->applyFilter($input); $img_nodes = $this->cssSelect('img'); - $this->assertCount(4, $img_nodes); + $this->assertCount(5, $img_nodes); $this->assertHasAttributes($img_nodes[0], [ 'alt' => 'default alt', 'title' => $expected_title_attributes[0], @@ -235,6 +239,10 @@ public function testOverridesAltAndTitle($title_field_property_enabled, array $e 'alt' => 'alt 3', 'title' => $expected_title_attributes[3], ]); + $this->assertHasAttributes($img_nodes[4], [ + 'alt' => '', + 'title' => $expected_title_attributes[4], + ]); } /** @@ -244,11 +252,11 @@ public function providerOverridesAltAndTitle() { return [ '`title` field property disabled ⇒ `title` is not overridable' => [ FALSE, - [NULL, NULL, NULL, NULL], + [NULL, NULL, NULL, NULL, NULL], ], - '`title` field property enabled ⇒ `title` is not overridable' => [ + '`title` field property enabled ⇒ `title` is overridable' => [ TRUE, - [NULL, 'title 1', 'title 2', 'title 3'], + [NULL, 'title 1', 'title 2', 'title 3', 'title 4'], ], ]; }