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..682862a03f 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' ||
@@ -152,6 +152,13 @@
               }
             });
           }
+          // @see media_field_widget_form_alter().
+          const hostEntityLangcode = document
+            .getElementById(editor.name)
+            .getAttribute('data-media-embed-host-entity-langcode');
+          if (hostEntityLangcode) {
+            data.hostEntityLangcode = hostEntityLangcode;
+          }
           return element;
         },
 
@@ -160,12 +167,35 @@
         },
 
         data(event) {
+          // Only run during changes.
+          if (this.oldData) {
+            // The server-side text filter plugin treats both an empty
+            // `data-caption` attribute and a non-existing one the same: it
+            // does not render a caption. But in the CKEditor Widget, we
+            // need to be able to show an empty caption with placeholder
+            // text using CSS even when technically there is no `data-caption`
+            // attribute value yet. That's why this CKEditor Widget has an
+            // independent `hasCaption` boolean (which is not an attribute)
+            // to know when to generate a non-empty `data-caption`
+            // attribute when the content creator has enabled caption: this
+            // makes the server-side text filter render a caption, allowing
+            // the placeholder-rendering CSS to work.
+            // @see core/modules/filter/css/filter.caption.css
+            // @see ckeditor_ckeditor_css_alter()
+            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._setUpEditButton();
               editor.fire('unlockSnapshot');
             });
           }
@@ -179,6 +209,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,9 +261,113 @@
               childList: true,
               subtree: true,
             });
+            // Some browsers will add a <br> tag to a newly created DOM
+            // element with no content. Remove this <br> 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.
+         */
+        _setUpEditButton() {
+          // 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(
+            `<button class="media-library-item__edit">${Drupal.t(
+              'Edit media',
+            )}</button>`,
+          );
+          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() {
           // If we are watching for changes to the caption, stop doing that.
           if (this.captionObserver) {
@@ -237,7 +378,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..3f3d2ebf96 100644
--- a/core/modules/media/js/plugins/drupalmedia/plugin.js
+++ b/core/modules/media/js/plugins/drupalmedia/plugin.js
@@ -120,18 +120,32 @@
               }
             });
           }
+
+          var hostEntityLangcode = document.getElementById(editor.name).getAttribute('data-media-embed-host-entity-langcode');
+          if (hostEntityLangcode) {
+            data.hostEntityLangcode = hostEntityLangcode;
+          }
           return element;
         },
         destroy: function destroy() {
           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._setUpEditButton();
               editor.fire('unlockSnapshot');
             });
           }
@@ -140,6 +154,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 +193,56 @@
               childList: true,
               subtree: true
             });
+
+            if (captionEditable.$.childNodes.length === 1 && captionEditable.$.childNodes.item(0).nodeName === 'BR') {
+              captionEditable.$.removeChild(captionEditable.$.childNodes.item(0));
+            }
+          }
+        },
+        _setUpEditButton: function _setUpEditButton() {
+          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('<button class="media-library-item__edit">' + Drupal.t('Edit media') + '</button>');
+          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.module b/core/modules/media/media.module
index 8f67df3703..a8cb3785f9 100644
--- a/core/modules/media/media.module
+++ b/core/modules/media/media.module
@@ -17,6 +17,7 @@
 use Drupal\Core\Url;
 use Drupal\field\FieldConfigInterface;
 use Drupal\media\Plugin\media\Source\OEmbedInterface;
+use Drupal\Core\Field\FieldItemListInterface;
 
 /**
  * Implements hook_help().
@@ -496,3 +497,17 @@ function media_filter_format_edit_form_validate($form, FormStateInterface $form_
     $form_state->setErrorByName('filters', $error_message);
   }
 }
+
+/**
+ * Implements hook_field_widget_form_alter().
+ */
+function media_field_widget_form_alter(&$element, FormStateInterface $form_state, $context) {
+  // Add an attribute so that drupalmedia's JavaScript can pass the host
+  // entity's language to EditorMediaDialog, allowing it to present entities
+  // in the same language.
+  if (!empty($element['#type']) && $element['#type'] == 'text_format') {
+    if (!empty($context['items']) && $context['items'] instanceof FieldItemListInterface) {
+      $element['#attributes']['data-media-embed-host-entity-langcode'] = $context['items']->getLangcode();
+    }
+  }
+}
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..d372bbd2a7
--- /dev/null
+++ b/core/modules/media/src/Form/EditorMediaDialog.php
@@ -0,0 +1,245 @@
+<?php
+
+namespace Drupal\media\Form;
+
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\editor\EditorInterface;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\HtmlCommand;
+use Drupal\editor\Ajax\EditorDialogSave;
+use Drupal\Core\Ajax\CloseModalDialogCommand;
+use Drupal\image\Plugin\Field\FieldType\ImageItem;
+use Drupal\media\MediaInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a media embed dialog for text editors.
+ *
+ * @internal
+ *   This is an internal part of the media system in Drupal core and may be
+ *   subject to change in minor releases. This class should not be
+ *   instantiated or extended by external code.
+ */
+class EditorMediaDialog extends FormBase {
+
+  /**
+   * The entity repository.
+   *
+   * @var \Drupal\Core\Entity\EntityRepositoryInterface
+   */
+  protected $entityRepository;
+
+  /**
+   * Constructs a EditorMediaDialog object.
+   *
+   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+   *   The entity repository.
+   */
+  public function __construct(EntityRepositoryInterface $entity_repository) {
+    $this->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'])) {
+      $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'] = '<div id="editor-media-dialog-form">';
+    $form['#suffix'] = '</div>';
+
+    $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']);
+
+    if (!empty($editor_object['hostEntityLangcode']) && $media->hasTranslation($editor_object['hostEntityLangcode'])) {
+      $media = $media->getTranslation($editor_object['hostEntityLangcode']);
+    }
+    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.<br />(Only in rare cases should this be left empty. To create empty alternative text, enter <code>""</code> — 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 ((empty($form['alt']) || $form['alt']['#access'] === FALSE) && $form['align']['#access'] === FALSE && $form['caption']['#access'] === FALSE) {
+      $format = $editor->getFilterFormat();
+      $warning = $this->t('There is nothing to configure for this media.');
+      $form['no_access_notice'] = ['#markup' => $warning];
+      if ($format->access('update')) {
+        $edit_url = $format->toUrl('edit-form')->toString();
+        $tparams = [
+          '%warning' => $warning,
+          '@edit_url' => $edit_url,
+          '%format' => $format->label(),
+        ];
+        $form['no_access_notice']['#markup'] = $this->t('%warning <a href="@edit_url">Edit the text format %format</a> to modify the attributes that can be overridden.', $tparams);
+      }
+    }
+
+    $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` attribute is optional: if it isn't set, the default value
+    // simply will not be overridden. It's important to set it to FALSE
+    // instead of unsetting the value.  This way 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..55683d4787 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, <code>&lt;drupal-media&gt;</code>. 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 <code>data-align</code> and/or <code>data-caption</code> attributes are allowed on the <code>&lt;drupal-media&gt;</code> tag.  If you would like users to be able to override the alt text on image media, add the <code>alt</code> attribute to the <code>&lt;drupal-media&gt;</code> tag as well."),
  *   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..24d2365fc0 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 <strong>landslide</strong>! No escape from <em>reality</em>!');
     $this->getSession()->switchToIFrame('ckeditor');
@@ -363,6 +397,382 @@ public function testEditableCaption() {
   }
 
   /**
+   * 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 = "<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type='1 A I'> <li> <dl> <dt> <dd> <h2 id='jump-*'> <h3 id> <h4 id> <h5 id> <h6 id> <drupal-media data-entity-type data-entity-uuid data-view-mode>";
+    $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 configure 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 configure 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('<drupal-media data-entity-type data-entity-uuid data-view-mode>', '<drupal-media alt data-align data-caption data-entity-type data-entity-uuid data-view-mode>', $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.
+    $field = FieldConfig::loadByName('media', 'image', 'field_media_image')
+      ->setSetting('alt_field', FALSE);
+    $field->save();
+    $this->container
+      ->get('cache.discovery')
+      ->delete('entity_bundle_field_definitions:media:image:en');
+    // 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();
+    $this->container
+      ->get('cache.discovery')
+      ->delete('entity_bundle_field_definitions:media:image:en');
+    // 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 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');
+    $assert_session->pageTextNotContains('There is nothing to configure for this media. Edit the text format Test format to modify the attributes that can be overridden.');
+    // 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();
+    // Test that by default no alt attribute is present on the drupal-media
+    // element.
+    $this->assertNotEmpty($drupal_media = $this->getDrupalMediaFromSource());
+    $this->assertFalse($drupal_media->hasAttribute('alt'));
+    // Press the source button again to 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');
+
+    // 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();
+
+    // Assert that the placeholder is set to the value of the media field's
+    // alt text.
+    $assert_session->elementAttributeContains('named', ['field', 'attributes[alt]'], 'placeholder', 'default alt');
+
+    // 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($assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $who_is_zartan . '")]'));
+
+    // Test that the downcast drupal-media element now has the alt attribute
+    // entered in the dialog.
+    $this->assertNotEmpty($drupal_media = $this->getDrupalMediaFromSource());
+    $this->assertSame($who_is_zartan, $drupal_media->getAttribute('alt'));
+    // Press the source button again to 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');
+
+    // Wait for the preview with the 'Edit media' button to load.
+    $this->assertNotEmpty($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 again 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.
+    $page->fillField('attributes[alt]', $cobra_commander_bio);
+    $this->submitDialog();
+    $this->getSession()->switchToIFrame('ckeditor');
+    // Assert that the img within the media embed preview
+    // within the ckeditor contains the overridden alt text set in the dialog.
+    $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $cobra_commander_bio . '")]'));
+
+    // Test that the downcast drupal-media element now has the alt attribute
+    // entered in the dialog.
+    $this->assertNotEmpty($drupal_media = $this->getDrupalMediaFromSource());
+    $this->assertSame($cobra_commander_bio, $drupal_media->getAttribute('alt'));
+    // Press the source button again to 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');
+
+    // Wait for the preview with the 'Edit media' button to load.
+    $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//img[contains(@alt, "' . $cobra_commander_bio . '")]'));
+
+    // Reopen the dialog.
+    $page->pressButton('Edit media');
+    $this->waitForMetadataDialog();
+
+    // The default value of the alt field should now display the override
+    // instead of the value on the media image field.
+    $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.
+    // Note: intentionally adding a space after the two double quotes to test
+    // the string is trimmed to two quotes.
+    $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 the downcast drupal-media element's alt attribute now has the
+    // empty string indicator.
+    $this->assertNotEmpty($drupal_media = $this->getDrupalMediaFromSource());
+    $this->assertTrue($drupal_media->getAttribute('alt'));
+    $this->assertSame('""', $drupal_media->getAttribute('alt'));
+
+    // Press the source button again to 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');
+
+    // Wait for the preview with the 'Edit media' button to load.
+    $this->assertNotEmpty($assert_session->waitForElementVisible('xpath', '//img[contains(@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 the downcast drupal-media element no longer has an alt
+    // attribute.
+    $this->assertNotEmpty($drupal_media = $this->getDrupalMediaFromSource());
+    $this->assertFalse($drupal_media->getAttribute('alt'));
+  }
+
+  /**
+   * Test that dialog loads appropriate translation's alt text.
+   */
+  public function testTranslationAlt() {
+    \Drupal::service('module_installer')->install(['language', 'content_translation']);
+    $this->resetAll();
+    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' => '<drupal-media data-caption="baz" data-entity-type="media" data-entity-uuid="' . $media->uuid() . '"></drupal-media>',
+        '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->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 = $assert_session->fieldExists('attributes[alt]')->getValue();
+    // Assert that the placeholder is set to the value of the media field's
+    // alt text.
+    $assert_session->elementAttributeContains('named', ['field', 'attributes[alt]'], 'placeholder', 'texte alternatif par défaut');
+
+    // 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 CKEditor contains
+    // the overridden alt text set in the dialog.
+    $this->assertNotEmpty($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.
    *
    * @dataProvider linkabilityProvider
@@ -611,34 +1021,125 @@ 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 = '<drupal-media data-align="' . $alignment . '" data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '"></drupal-media>';
-      $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.
+      $selector = sprintf('drupal-media[data-align="%s"] .caption-drupal-media.align-%s', $alignment, $alignment);
+      $this->assertNotEmpty($assert_session->waitForElementVisible('css', $selector, 2000));
+      // Assert that the resultant downcast <drupal-media> tag has the proper
+      // `data-align` attribute.
+      $this->assertNotEmpty($drupal_media = $this->getDrupalMediaFromSource());
+      $this->assertSame($alignment, $drupal_media->getAttribute('data-align'));
+
+      // Press the source button again to 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.
+    $this->assertNotEmpty($drupal_media = $this->getDrupalMediaFromSource());
+    $this->assertFalse($drupal_media->hasAttribute('data-align'));
+  }
+
+  /**
+   * Waits for the form that allows editing metadata.
+   *
+   * @see \Drupal\media\Form\EditorMediaDialog
+   */
+  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 +1246,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 +1253,26 @@ protected function clickPathLinkByTitleAttribute($text) {
     $this->assertSession()->elementExists('xpath', $selector)->click();
   }
 
+  /**
+   * Parses the <drupal-media> element from CKEditor's "source" view.
+   *
+   * @return \DOMNode|null
+   *   The drupal-media element or NULL if it can't be found.
+   */
+  protected function getDrupalMediaFromSource() {
+    $this->pressEditorButton('source');
+    $value = $this->assertSession()
+      ->elementExists('css', 'textarea.cke_source')
+      ->getValue();
+    $dom = Html::load($value);
+    $xpath = new \DOMXPath($dom);
+    $list = $xpath->query('//drupal-media');
+    if (!empty($list[0])) {
+      $drupal_media = $list[0];
+      $this->assertInstanceOf(\DOMNode::class, $drupal_media);
+      return $drupal_media;
+    }
+    return NULL;
+  }
+
 }
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'],
       ],
     ];
   }
