diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js index 81f5561fab..25103e92de 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js @@ -160,12 +160,28 @@ }, data(event) { + // 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) { + delete this.data.attributes['data-caption']; + } else if ( + this.data.hasCaption && + this.oldData && + !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'); }); } @@ -180,7 +196,6 @@ .getParent() .addClass(`align-${this.data.attributes['data-align']}`); } - // Track the previous state to allow checking if preview needs // server side update. this.oldData = CKEDITOR.tools.clone(this.data); @@ -224,7 +239,125 @@ 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 element is a node. + * + * Returns true if element.nodeType is equal to 1, meaning a node + * element and not a non-node element (such as text). + * + * @see https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR.html#property-NODE_ELEMENT + * + * @return {bool} + */ + 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 and remove buttons to be absolutely positioned + // the parent element must be position relative. + embeddedMedia.setStyle('position', 'relative'); + const editButton = CKEDITOR.dom.element.createFromHtml( + `${Drupal.t( + 'Edit media', + )}`, + ); + embeddedMedia.getFirst().insertBeforeMe(editButton); + + const deleteButton = CKEDITOR.dom.element.createFromHtml( + `${Drupal.t( + 'Remove media', + )}`, + ); + embeddedMedia.getFirst().insertBeforeMe(deleteButton); + + // Make the buttons 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, + // Coerces to boolean. If it was falsey (e.g. 0, null, + // undefined, etc.), it will be false, otherwise, true. + // @see https://stackoverflow.com/a/10597474/1214689 + hasCaption: !!values.hasCaption, + }); + editor.fire('saveSnapshot'); + }; + + Drupal.ckeditor.openDialog( + editor, + Drupal.url( + `editor/dialog/media/${editor.config.drupal.format}`, + ), + widget.data, + saveCallback, + {}, + ); + }); + this.element + .findOne('.media-library-item__remove') + .on('click', event => { + event.cancel(); + editor.fire('saveSnapshot'); + widget.repository.del(widget); + editor.fire('saveSnapshot'); + }); }, _tearDownDynamicEditables() { @@ -237,7 +370,7 @@ /** * Determines if the preview needs to be re-rendered by the server. * - * @returns {boolean} + * @return {boolean} */ _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..a154b440f6 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.js @@ -126,12 +126,19 @@ this._tearDownDynamicEditables(); }, data: function data(event) { + if (!this.data.hasCaption) { + delete this.data.attributes['data-caption']; + } else if (this.data.hasCaption && this.oldData && !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'); }); } @@ -172,7 +179,65 @@ 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('' + Drupal.t('Edit media') + ''); + embeddedMedia.getFirst().insertBeforeMe(editButton); + + var deleteButton = CKEDITOR.dom.element.createFromHtml('' + Drupal.t('Remove media') + ''); + embeddedMedia.getFirst().insertBeforeMe(deleteButton); + + 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, {}); + }); + this.element.findOne('.media-library-item__remove').on('click', function (event) { + event.cancel(); + editor.fire('saveSnapshot'); + widget.repository.del(widget); + editor.fire('saveSnapshot'); + }); }, _tearDownDynamicEditables: function _tearDownDynamicEditables() { if (this.captionObserver) { diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 19dadf9e01..6946a205a7 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -48,3 +48,11 @@ 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' + 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..790f9bdfd8 --- /dev/null +++ b/core/modules/media/src/Form/EditorMediaDialog.php @@ -0,0 +1,252 @@ +entityRepository = $entity_repository; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.repository') + ); + } + + /** + * {@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'])) { + // By convention, the data that the text editor sends to any dialog is in + // the 'editor_object' key. + $editor_object = $form_state->getUserInput()['editor_object']; + if (empty($editor_object['attributes'])) { + throw new MissingDataException("Unable to find the required media embed data."); + } + $media_embed_element = $editor_object['attributes']; + $form_state->set('media_embed_element', $media_embed_element); + $form_state->set('hasCaption', !empty($editor_object['hasCaption'])); + $form_state->setCached(TRUE); + } + elseif ($form_state->has('media_embed_element')) { + // Retrieve the user input from form state. + $media_embed_element = $form_state->get('media_embed_element'); + $has_caption = $form_state->get('hasCaption'); + } + else { + throw new BadRequestHttpException("Unable to find the required media embed data."); + } + + $form['#tree'] = TRUE; + $form['#attached']['library'][] = 'editor/drupal.editor.dialog'; + $form['#prefix'] = '
'; + $form['#suffix'] = '
'; + + // The alt attribute is *required*, but we allow users to opt-in to empty + // alt attributes for the very rare edge cases where that is valid by + // specifying two double quotes as the alternative text in the dialog. + // However, that *is* stored as an empty alt attribute, so if we're editing + // an existing image (which means the src attribute is set) and its alt + // attribute is empty, then we show that as two double quotes in the dialog. + // @see https://www.drupal.org/node/2307647 + $media = $this->entityRepository->loadEntityByUuid('media', $media_embed_element['data-entity-uuid']); + if ($image_field = $this->getMediaImageSourceField($media)) { + $settings = $media->{$image_field}->getItemDefinition()->getSettings(); + $alt = isset($media_embed_element['alt']) ? $media_embed_element['alt'] : NULL; + $title = isset($media_embed_element['title']) ? $media_embed_element['title'] : NULL; + if (!empty($settings['alt_field'])) { + $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' => 512, + '#placeholder' => $media->{$image_field}->alt, + '#parents' => ['attributes', 'alt'], + ]; + } + if (!empty($settings['title_field'])) { + $form['title'] = [ + '#type' => 'textfield', + '#title' => $this->t('Title'), + '#default_value' => $title, + '#description' => t('The title is used as a tool tip when the user hovers the mouse over the image.'), + // Maxlengths for `title`and `alt` fields follow ImageItem schema. + // @See core/modules/image/src/Plugin/Field/FieldType/ImageItem::schema() + '#maxlength' => 1024, + '#placeholder' => $media->{$image_field}->title, + '#parents' => ['attributes', 'title'], + ]; + } + } + + // When Drupal core's filter_align is being used, the text editor offers the + // ability to change the alignment. + if ($editor->getFilterFormat()->filters('filter_align')->status) { + $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'], + '#wrapper_attributes' => ['class' => ['container-inline']], + '#attributes' => ['class' => ['container-inline']], + '#parents' => ['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. + if ($editor->getFilterFormat()->filters('filter_caption')->status) { + $form['caption'] = [ + '#title' => $this->t('Caption'), + '#type' => 'checkbox', + '#default_value' => $has_caption === 'true', + '#parents' => ['hasCaption'], + ]; + } + + $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. + if ($form_state->hasValue(['attributes', 'alt']) && trim($form_state->getValue(['attributes', 'alt'])) === '') { + $form_state->unsetValue(['attributes', 'alt']); + } + if ($form_state->hasValue(['attributes', 'title']) && trim($form_state->getValue(['attributes', 'title'])) === '') { + $form_state->unsetValue(['attributes', 'title']); + } + + // If `data-align`is set to "none", remove the attribute. + if ($form_state->hasValue(['attributes', 'data-align']) && $form_state->getValue(['attributes', 'data-align']) === 'none') { + $form_state->unsetValue(['attributes', 'data-align']); + } + + 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 + * 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 (is_a($item_class, ImageItem::class)) { + 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..b01a9d23e4 100644 --- a/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php +++ b/core/modules/media/src/Plugin/CKEditorPlugin/DrupalMedia.php @@ -124,6 +124,7 @@ 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', + $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..502a0af142 100644 --- a/core/modules/media/src/Plugin/Filter/MediaEmbed.php +++ b/core/modules/media/src/Plugin/Filter/MediaEmbed.php @@ -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 @@ -428,6 +435,9 @@ protected function applyPerEmbedMediaOverrides(\DOMElement $node, MediaInterface } if (!empty($settings['title_field']) && $node->hasAttribute('title')) { + if ($node->getAttribute('title') === '""') { + $node->setAttribute('title', NULL); + } // See above, the explanations for `alt` also apply to `title`. $media->{$image_field}->title = $node->getAttribute('title'); $media->thumbnail->title = $node->getAttribute('title'); diff --git a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php index d0e7588697..911654099e 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\media\FunctionalJavascript; use Drupal\Component\Utility\Html; +use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\Core\Url; use Drupal\editor\Entity\Editor; use Drupal\file\Entity\File; @@ -86,6 +87,8 @@ protected function setUp() { 'DrupalLink', 'DrupalUnlink', 'DrupalImage', + 'Undo', + 'Redo', ], ], ], @@ -139,6 +142,8 @@ protected function setUp() { * @see \Drupal\Tests\media\Kernel\MediaEmbedFilterTest::testOnlyDrupalMediaTagProcessed() */ public function testOnlyDrupalMediaTagProcessed() { + $session = $this->getSession(); + $page = $session->getPage(); $original_value = $this->host->body->value; $this->host->body->value = str_replace('drupal-media', 'p', $original_value); $this->host->save(); @@ -147,7 +152,7 @@ public function testOnlyDrupalMediaTagProcessed() { $this->drupalGet($this->host->toUrl('edit-form')); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session = $this->assertSession(); $this->assertEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]', 1000)); $assert_session->elementNotExists('css', 'figure'); @@ -156,10 +161,10 @@ public function testOnlyDrupalMediaTagProcessed() { $this->host->save(); // Assert that `` is upcast into a CKEditor Widget. - $this->getSession()->reload(); + $session->reload(); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]')); $assert_session->elementExists('css', 'figure'); } @@ -168,6 +173,7 @@ public function testOnlyDrupalMediaTagProcessed() { * Tests that failed media embed preview requests inform the end user. */ public function testPreviewFailure() { + $session = $this->getSession(); // Assert that a request to the `media.filter.preview` route that does not // result in a 200 response (due to server error or network error) is // handled in the JavaScript by displaying the expected error message. @@ -175,7 +181,7 @@ public function testPreviewFailure() { $this->drupalGet($this->host->toUrl('edit-form')); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session = $this->assertSession(); $this->assertEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]', 1000)); $assert_session->elementNotExists('css', 'figure'); @@ -185,10 +191,10 @@ public function testPreviewFailure() { // Now assert that the error doesn't appear when the override to force an // error is removed. $this->container->get('state')->set('test_media_filter_controller_throw_error', FALSE); - $this->getSession()->reload(); + $session->reload(); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]')); } @@ -196,6 +202,7 @@ public function testPreviewFailure() { * The CKEditor Widget must load a preview generated using the default theme. */ public function testPreviewUsesDefaultThemeAndIsClientCacheable() { + $session = $this->getSession(); // Make the node edit form use the admin theme, like on most Drupal sites. $this->config('node.settings') ->set('use_admin_theme', TRUE) @@ -218,7 +225,7 @@ public function testPreviewUsesDefaultThemeAndIsClientCacheable() { $this->drupalGet($this->host->toUrl('edit-form')); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session = $this->assertSession(); $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]')); $element = $assert_session->elementExists('css', '[data-media-embed-test-active-theme]'); @@ -235,7 +242,7 @@ public function testPreviewUsesDefaultThemeAndIsClientCacheable() { $this->assertNotEmpty($assert_session->waitForElement('css', 'textarea.cke_source')); $this->pressEditorButton('source'); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]')); $this->assertSame(0, $this->getLastPreviewRequestTransferSize()); } @@ -244,23 +251,52 @@ public function testPreviewUsesDefaultThemeAndIsClientCacheable() { * Tests caption editing in the CKEditor widget. */ public function testEditableCaption() { + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); $this->drupalGet($this->host->toUrl('edit-form')); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - + $session->switchToIFrame('ckeditor'); + // Assert that figcaption element exists within the drupal-media element. + $figcaption = $assert_session->waitForElement('css', 'figcaption'); + $this->assertNotEmpty($figcaption); + $this->assertSame('baz', $figcaption->getHtml()); + // Test that disabling the caption in the metadata dialog removes it + // from the drupal-media element. + $page->clickLink('Edit media'); + $this->waitforMetadataDialog(); + $page->uncheckField('hasCaption'); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + $drupal_media = $assert_session->waitForElementVisible('css', 'drupal-media', 2000); + $this->assertNotEmpty($drupal_media); + // 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. + $this->assertTrue($result); + // Test that enabling the caption in the metadata dialog adds an editable + // caption to the embedded media. + $page->clickLink('Edit media'); + $this->waitforMetadataDialog(); + $page->checkField('hasCaption'); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + $drupal_media = $assert_session->waitForElementVisible('css', 'drupal-media figcaption', 2000); + $this->assertNotEmpty($drupal_media); // Type in the widget's editable for the caption. - $this->getSession()->switchToIFrame('ckeditor'); - $assert_session = $this->assertSession(); $this->assertNotEmpty($assert_session->waitForElement('css', 'figcaption')); $this->setCaption('Caught in a landslide! No escape from reality!'); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session->elementExists('css', 'figcaption > em'); $assert_session->elementExists('css', 'figcaption > strong')->click(); // Select the element and unbold it. $this->clickPathLinkByTitleAttribute("strong element"); $this->pressEditorButton('bold'); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session->elementExists('css', 'figcaption > em'); $assert_session->elementNotExists('css', 'figcaption > strong'); @@ -286,7 +322,7 @@ public function testEditableCaption() { $source->setValue(Html::serialize($dom)); $this->pressEditorButton('source'); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $figcaption = $assert_session->waitForElement('css', 'figcaption'); $this->assertNotEmpty($figcaption); $this->assertSame($poor_boy_text, $figcaption->getHtml()); @@ -308,9 +344,9 @@ public function testEditableCaption() { // Wait for the live preview in the CKEditor widget to finish loading, then // edit the link; no `data-cke-saved-href` attribute should exist on it. - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $figcaption = $assert_session->waitForElement('css', 'figcaption'); - $page = $this->getSession()->getPage(); + // Wait for AJAX refresh. $page->waitFor(10, function () use ($figcaption) { return $figcaption->find('xpath', '//a[@href="https://www.drupal.org"]'); @@ -322,9 +358,8 @@ public function testEditableCaption() { $this->assertNotEmpty($field); $field->setValue('https://www.drupal.org/project/drupal'); $assert_session->elementExists('css', 'button.form-submit')->press(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $figcaption = $assert_session->waitForElement('css', 'figcaption'); - $page = $this->getSession()->getPage(); // Wait for AJAX refresh. $page->waitFor(10, function () use ($figcaption) { return $figcaption->find('xpath', '//a[@href="https://www.drupal.org/project/drupal"]'); @@ -347,27 +382,184 @@ public function testEditableCaption() { $this->drupalGet($this->host->toUrl('edit-form')); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'figcaption')); $this->setCaption('Scaramouch, Scaramouch, will you do the Fandango?'); // Erase the caption in the CKEditor Widget, verify the
still // exists and contains placeholder text, then type something else. $this->setCaption(''); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session->elementContains('css', 'figcaption', ''); $assert_session->elementAttributeContains('css', 'figcaption', 'data-placeholder', 'Enter caption here'); $this->setCaption('Fin.'); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session->elementContains('css', 'figcaption', 'Fin.'); } + /** + * Tests the EditorMediaDialog can set alt and title attributes. + */ + public function testAltAndTitle() { + // Update the default media view mode to use image formatter, + // to test title field override. + EntityViewDisplay::create([ + 'targetEntityType' => 'media', + 'bundle' => 'image', + 'mode' => 'default', + 'status' => TRUE, + ])->setComponent('field_media_image', [ + 'type' => 'image', + 'weight' => 2, + 'region' => 'content', + 'label' => 'hidden', + 'settings' => [ + 'image_style' => 'medium', + 'image_link' => '', + ], + 'third_party_settings' => [], + ])->removeComponent('thumbnail') + ->save(); + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $session->switchToIFrame('ckeditor'); + + $config_combinations = [ + [ + 'settings' => [ + 'settings.alt_field' => FALSE, + 'settings.title_field' => FALSE, + ], + 'expected_fields' => [ + 'attributes[alt]' => FALSE, + 'attributes[title]' => FALSE, + ], + ], + [ + 'settings' => [ + 'settings.alt_field' => TRUE, + 'settings.title_field' => FALSE, + ], + 'expected_fields' => [ + 'attributes[alt]' => TRUE, + 'attributes[title]' => FALSE, + ], + ], + [ + 'settings' => [ + 'settings.alt_field' => FALSE, + 'settings.title_field' => TRUE, + ], + 'expected_fields' => [ + 'attributes[alt]' => FALSE, + 'attributes[title]' => TRUE, + ], + ], + [ + 'settings' => [ + 'settings.alt_field' => TRUE, + 'settings.title_field' => TRUE, + ], + 'expected_fields' => [ + 'attributes[alt]' => TRUE, + 'attributes[title]' => TRUE, + ], + ], + ]; + + foreach ($config_combinations as $data) { + $this->config('field.field.media.image.field_media_image') + ->set('settings.alt_field', $data['settings']['settings.alt_field']) + ->set('settings.title_field', $data['settings']['settings.title_field']) + ->save(); + $this->container->get('cache.discovery')->deleteAll(); + // Wait for preview. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->clickLink('Edit media'); + $this->waitForMetadataDialog(); + $alt = !empty($page->findField('attributes[alt]')); + $this->assertSame($data['expected_fields']['attributes[alt]'], $alt); + $title = !empty($page->findField('attributes[title]')); + $this->assertSame($data['expected_fields']['attributes[title]'], $title); + $page->pressButton('Close'); + $session->switchToIFrame('ckeditor'); + } + + $img = $assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "default alt")]'); + $this->assertEquals('default title', $img->getAttribute('title')); + $page->clickLink('Edit media'); + $this->waitForMetadataDialog(); + $alt = $page->findField('attributes[alt]'); + $title = $page->findField('attributes[title]'); + // Assert that the placeholder is set to the value of the media field's alt text. + $this->assertSame('default alt', $alt->getAttribute('placeholder')); + $this->assertSame('default title', $title->getAttribute('placeholder')); + $who_is_zartan = 'Zartan is the leader of the Dreadnoks.'; + $decepticons = 'Robotic lifeforms from the planet Cybertron'; + $page->fillField('attributes[alt]', $who_is_zartan); + $page->fillField('attributes[title]', $decepticons); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + $img = $assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $who_is_zartan . '")]'); + $this->assertNotEmpty($img); + $this->assertSame($decepticons, $img->getAttribute('title')); + $page->clickLink('Edit media'); + $this->waitForMetadataDialog(); + $alt = $page->findField('attributes[alt]'); + $title = $page->findField('attributes[title]'); + $this->assertSame($who_is_zartan, $alt->getValue()); + $this->assertSame($decepticons, $title->getValue()); + $cobra_commander_bio = 'The supreme leader of the terrorist organization Cobra'; + $alt->setValue($cobra_commander_bio); + $who_is_megatron = 'the evil Decepticon leader'; + $title->setValue($who_is_megatron); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + $img = $assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $cobra_commander_bio . '")]'); + $this->assertNotEmpty($img); + $this->assertSame($who_is_megatron, $img->getAttribute('title')); + $page->clickLink('Edit media'); + $this->waitForMetadataDialog(); + $alt = $page->findField('attributes[alt]'); + $title = $page->findField('attributes[title]'); + $this->assertSame($cobra_commander_bio, $alt->getValue()); + $this->assertSame($who_is_megatron, $title->getValue()); + // Test that setting value to double quote will allow setting the alt + // and title to empty. + $alt->setValue('""'); + $title->setValue('""'); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + $img = $assert_session->waitForElementVisible('xpath', '//img'); + // Wait for element to update. + $result = $page->waitFor(10, function () use ($img, $cobra_commander_bio) { + return ($img->getAttribute('alt') !== $cobra_commander_bio); + }); + $this->assertTrue($result); + $this->assertEmpty($img->getAttribute('alt')); + $this->assertEmpty($img->getAttribute('title')); + $page->clickLink('Edit media'); + $this->waitForMetadataDialog(); + // Test that setting value to back to empty string restores the default values. + $page->fillField('attributes[alt]', ""); + $page->fillField('attributes[title]', ""); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + $img = $assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "default alt")]'); + $this->assertEquals('default title', $img->getAttribute('title')); + } + /** * Tests linkability of the CKEditor widget. * * @dataProvider linkabilityProvider */ public function testLinkability($drupalimage_is_enabled) { + $session = $this->getSession(); if (!$drupalimage_is_enabled) { // Remove the `drupalimage` plugin's `DrupalImage` button. $editor = Editor::load('test_format'); @@ -377,9 +569,11 @@ public function testLinkability($drupalimage_is_enabled) { foreach ($row as $group_key => $group) { foreach ($group['items'] as $item_key => $item) { if ($item === 'DrupalImage') { - unset($settings['toolbar']['rows'][$row_key][$group_key]['items'][$item_key]); + unset($group['items'][$item_key]); + break; } } + $settings['toolbar']['rows'][$row_key][$group_key]['items'] = array_values($group['items']); } } $editor->setSettings($settings); @@ -398,7 +592,7 @@ public function testLinkability($drupalimage_is_enabled) { $this->drupalGet($this->host->toUrl('edit-form')); $this->waitForEditor(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session = $this->assertSession(); // Select the CKEditor Widget. @@ -410,7 +604,7 @@ public function testLinkability($drupalimage_is_enabled) { // contain link-related context menu items. $this->openContextMenu(); $this->assignNameToCkeditorPanelIframe(); - $this->getSession()->switchToIFrame('panel'); + $session->switchToIFrame('panel'); $this->assertContextMenuItemNotExists('Edit Link'); $this->assertContextMenuItemNotExists('Unlink'); $this->closeContextMenu(); @@ -424,7 +618,7 @@ public function testLinkability($drupalimage_is_enabled) { $this->assertNotEmpty($field); $field->setValue('https://www.drupal.org'); $assert_session->elementExists('css', 'button.form-submit')->press(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $link = $assert_session->waitForElementVisible('css', 'a[href="https://www.drupal.org"]'); $this->assertNotEmpty($link); @@ -434,13 +628,13 @@ public function testLinkability($drupalimage_is_enabled) { $this->assertNotEmpty($drupalmedia); $drupalmedia->click(); $this->openContextMenu(); - $this->getSession()->switchToIFrame('panel'); + $session->switchToIFrame('panel'); $this->assertContextMenuItemExists('Edit Link'); $this->assertContextMenuItemExists('Unlink'); $this->closeContextMenu(); // Save the entity. - $this->getSession()->switchToIFrame(); + $session->switchToIFrame(); $assert_session->buttonExists('Save')->press(); // Verify the saved entity when viewed also contains the linked media. @@ -457,7 +651,7 @@ public function testLinkability($drupalimage_is_enabled) { $field->setValue('https://wikipedia.org'); $assert_session->elementExists('css', 'button.form-submit')->press(); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $link = $assert_session->waitForElementVisible('css', 'body > a[href="https://wikipedia.org"]'); $this->assertNotEmpty($link); $assert_session->elementExists('css', 'body > .cke_widget_drupalmedia > drupal-media > figure > a[href="https://www.drupal.org"]'); @@ -469,30 +663,30 @@ public function testLinkability($drupalimage_is_enabled) { $this->assertNotEmpty($drupalmedia); $drupalmedia->click(); $this->openContextMenu(); - $this->getSession()->switchToIFrame(); + $session->switchToIFrame(); $this->assertEditorButtonEnabled('drupalunlink'); $this->assignNameToCkeditorPanelIframe(); - $this->getSession()->switchToIFrame('panel'); + $session->switchToIFrame('panel'); $this->assertContextMenuItemExists('Edit Link'); $this->assertContextMenuItemExists('Unlink'); // Test that moving focus to another element causes the `drupalunlink` // button to become disabled and causes link-related context menu items to // disappear. - $this->getSession()->switchToIFrame(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame(); + $session->switchToIFrame('ckeditor'); $p = $assert_session->waitForElementVisible('xpath', "//p[contains(text(), 'The pirate is irate')]"); $this->assertNotEmpty($p); $p->click(); $this->assertEditorButtonDisabled('drupalunlink'); - $this->getSession()->switchToIFrame('panel'); + $session->switchToIFrame('panel'); $this->assertContextMenuItemExists('Edit Link'); $this->assertContextMenuItemExists('Unlink'); // To switch from the context menu iframe ("panel") back to the CKEditor // iframe, we first have to reset to top frame. - $this->getSession()->switchToIFrame(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame(); + $session->switchToIFrame('ckeditor'); // Test that moving focus to the `drupalimage` CKEditor Widget enables the // `drupalunlink` button again, because it is a linked image. @@ -501,22 +695,22 @@ public function testLinkability($drupalimage_is_enabled) { $this->assertNotEmpty($drupalimage); $drupalimage->click(); $this->assertEditorButtonEnabled('drupalunlink'); - $this->getSession()->switchToIFrame('panel'); + $session->switchToIFrame('panel'); $this->assertContextMenuItemExists('Edit Link'); $this->assertContextMenuItemExists('Unlink'); - $this->getSession()->switchToIFrame(); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame(); + $session->switchToIFrame('ckeditor'); } // Tests the `drupalunlink` button for the `drupalmedia` CKEditor Widget. $drupalmedia->click(); $this->assertEditorButtonEnabled('drupalunlink'); - $this->getSession()->switchToIFrame('panel'); + $session->switchToIFrame('panel'); $this->assertContextMenuItemExists('Edit Link'); $this->assertContextMenuItemExists('Unlink'); $this->pressEditorButton('drupalunlink'); $this->assertEditorButtonDisabled('drupalunlink'); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session->elementNotExists('css', 'figure > a[href="https://www.drupal.org"] > .media--type-image > .field--type-image > img[src*="image-test.png"]'); $assert_session->elementExists('css', 'figure .media--type-image > .field--type-image > img[src*="image-test.png"]'); if ($drupalimage_is_enabled) { @@ -525,7 +719,7 @@ public function testLinkability($drupalimage_is_enabled) { $this->assertEditorButtonEnabled('drupalunlink'); $this->pressEditorButton('drupalunlink'); $this->assertEditorButtonDisabled('drupalunlink'); - $this->getSession()->switchToIFrame('ckeditor'); + $session->switchToIFrame('ckeditor'); $assert_session->elementNotExists('css', 'p > a[href="https://www.drupal.org/association"] > img[src*="image-test.png"]'); $assert_session->elementExists('css', 'p > img[src*="image-test.png"]'); } @@ -611,28 +805,163 @@ 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 testAlignmentIntegration() { + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $session->switchToIFrame('ckeditor'); $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->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->clickLink('Edit media'); + $this->waitforMetadataDialog(); + $page->fillField('attributes[data-align]', $alignment); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + // Assert that data-align property is converted to either align-right', + // `align-left` or `align-center' on widget, caption figure and + // drupal-media tag. + $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//drupal-media[@data-align="' . $alignment . '"]', 2000)); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.caption-drupal-media.align-' . $alignment, 2000)); + $widget = $assert_session->elementExists('css', '.cke_widget_drupalmedia.align-' . $alignment); + $this->assertNotEmpty($widget); + + // Assert that the resultant downcast drupal-media tag has the proper + // `data-align` attribute. + $this->pressEditorButton('source'); + $source = $assert_session->elementExists('css', 'textarea.cke_source'); + $value = $source->getValue(); + $dom = Html::load($value); + $xpath = new \DOMXPath($dom); + $drupal_media = $xpath->query('//drupal-media')[0]; + $this->assertSame($alignment, $drupal_media->getAttribute('data-align')); + $this->pressEditorButton('source'); $this->assignNameToCkeditorIframe(); - $this->getSession()->switchToIFrame('ckeditor'); - $wrapper = $assert_session->waitForElementVisible('css', '.cke_widget_drupalmedia', 2000); - $this->assertNotEmpty($wrapper); - $this->assertTrue($wrapper->hasClass('align-' . $alignment)); + $session->switchToIFrame('ckeditor'); + } + // Test that setting alignment to none removes the attribute. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'drupal-media', 2000)); + $page->clickLink('Edit media'); + $this->waitforMetadataDialog(); + $page->fillField('attributes[data-align]', 'none'); + $this->submitDialog(); + $session->switchToIFrame('ckeditor'); + $drupal_media = $assert_session->waitForElementVisible('xpath', '//drupal-media', 2000); + $this->assertNotEmpty($drupal_media); + if ($drupal_media->hasAttribute('data-align')) { + // Wait for element to update. + $page->waitFor(10, function () use ($drupal_media) { + return empty($drupal_media->hasAttribute('data-align')); + }); + } + $this->assertFalse($drupal_media->hasAttribute('data-align')); + // Assert that neither the widget nor the caption figure have alignment classes. + $figure = $assert_session->waitForElementVisible('css', '.caption-drupal-media', 2000); + $this->assertNotEmpty($figure); + $widget = $assert_session->elementExists('css', '.cke_widget_drupalmedia'); + $this->assertNotEmpty($widget); + foreach ($alignments as $alignment) { + $this->assertFalse($figure->hasClass('align-' . $alignment)); + $this->assertFalse($widget->hasClass('align-' . $alignment)); } + // Assert that the resultant downcast drupal-media tag has no data-align + // attribute. + $this->pressEditorButton('source'); + $source = $assert_session->elementExists('css', 'textarea.cke_source'); + $value = $source->getValue(); + $dom = Html::load($value); + $xpath = new \DOMXPath($dom); + $drupal_media = $xpath->query('//drupal-media')[0]; + $this->assertEmpty($drupal_media->getAttribute('data-align')); + } + + /** + * Tests the delete button within the drupalmedia widget. + */ + public function testDeleteButton() { + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); + $this->drupalGet($this->host->toUrl('edit-form')); + $this->waitForEditor(); + $this->assignNameToCkeditorIframe(); + $session->switchToIFrame('ckeditor'); + $drupal_media = $assert_session->waitForElementVisible('css', 'drupal-media', 2000); + $this->assertNotEmpty($drupal_media); + $assert_session->linkExists('Edit media'); + // Test that the delete button removes the media. + $page->clickLink('Delete media'); + $result = $page->waitFor(10, function () use ($page) { + return empty($page->find('css', 'drupal-media')); + }); + $this->assertTrue($result); + // Test that undo button restores the deleted media embed. + $this->pressEditorButton('undo'); + $session->switchToIFrame('ckeditor'); + $drupal_media = $assert_session->waitForElementVisible('css', 'drupal-media', 2000); + $this->assertNotEmpty($drupal_media); + // Test that the redo button removes the media again. + $this->pressEditorButton('redo'); + $result = $page->waitFor(10, function () use ($page) { + return empty($page->find('css', 'drupal-media')); + }); + $this->assertTrue($result); + } + + /** + * 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); + } + + /** + * Closes and submits the metadata dialog. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + */ + protected function submitDialog() { + $dialog_buttons = $this->assertSession() + ->elementExists('css', 'div.ui-dialog-buttonpane'); + $this->assertNotEmpty($dialog_buttons); + $dialog_buttons->pressButton('Save'); + } + + /** + * Closes the metadata dialog. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + */ + 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); } /**