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'] = '
""
— 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 = " -
-
-
";
+ $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'],
],
];
}