diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js index 1c5c953617..482588de84 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.es6.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.es6.js @@ -147,6 +147,21 @@ }, data(event) { + + 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(); @@ -201,6 +216,12 @@ childList: true, subtree: true, }); + // When starting out with an empty caption, CKEditor automatically + // injects a
that we need to delete. + // @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)); + } } }, diff --git a/core/modules/media/js/plugins/drupalmedia/plugin.js b/core/modules/media/js/plugins/drupalmedia/plugin.js index e1cb060051..bacc1afb39 100644 --- a/core/modules/media/js/plugins/drupalmedia/plugin.js +++ b/core/modules/media/js/plugins/drupalmedia/plugin.js @@ -128,6 +128,15 @@ 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(); @@ -174,6 +183,10 @@ childList: true, subtree: true }); + + if (captionEditable.$.childNodes.length === 1 && captionEditable.$.childNodes.item(0).nodeName === 'BR') { + captionEditable.$.removeChild(captionEditable.$.childNodes.item(0)); + } } }, _tearDownDynamicEditables: function _tearDownDynamicEditables() { diff --git a/core/modules/media_library/css/media_library.module.css b/core/modules/media_library/css/media_library.module.css index 3e2cdde7d1..e95f1ca329 100644 --- a/core/modules/media_library/css/media_library.module.css +++ b/core/modules/media_library/css/media_library.module.css @@ -2,6 +2,11 @@ * @file media_library.module.css */ +/* Work around dialogOptions imposed by Drupal.ckeditor.openDialog(). */ +.ui-dialog--narrow.media-library-widget-modal { + max-width: 75%; +} + .media-library-wrapper { display: flex; } diff --git a/core/modules/media_library/js/plugins/drupalmedialibrary/plugin.es6.js b/core/modules/media_library/js/plugins/drupalmedialibrary/plugin.es6.js new file mode 100644 index 0000000000..a35512873b --- /dev/null +++ b/core/modules/media_library/js/plugins/drupalmedialibrary/plugin.es6.js @@ -0,0 +1,51 @@ +/** + * @file + * Drupal Media Library plugin. + */ + +(function (Drupal, CKEDITOR) { + + "use strict"; + + CKEDITOR.plugins.add('drupalmedialibrary', { + requires: 'drupalmedia', + + beforeInit(editor) { + editor.addCommand('drupalmedialibrary', { + allowedContent: 'drupal-media[!data-entity-type,!data-entity-uuid,data-align,data-caption,alt,title]', + requiredContent: 'drupal-media[data-entity-type,data-entity-uuid]', + modes: { wysiwyg: 1 }, + canUndo: true, + exec: function (editor) { + const saveCallback = function (values) { + editor.fire('saveSnapshot'); + let mediaElement = editor.document.createElement('drupal-media'); + const attributes = values.attributes; + for (let key in attributes) { + mediaElement.setAttribute(key, attributes[key]); + } + editor.insertHtml(mediaElement.getOuterHtml()); + editor.fire('saveSnapshot'); + }; + + // @see \Drupal\media_library\MediaLibraryUiBuilder::dialogOptions() + Drupal.ckeditor.openDialog( + editor, + editor.config.DrupalMediaLibrary_url, + {}, + saveCallback, + editor.config.DrupalMediaLibrary_dialogOptions, + ); + } + }); + + if (editor.ui.addButton) { + editor.ui.addButton('DrupalMediaLibrary', { + label: Drupal.t('Insert from Media Library'), + command: 'drupalmedialibrary', + }); + } + } + }); + +})(Drupal, CKEDITOR); diff --git a/core/modules/media_library/js/plugins/drupalmedialibrary/plugin.js b/core/modules/media_library/js/plugins/drupalmedialibrary/plugin.js new file mode 100644 index 0000000000..25cdcc4d35 --- /dev/null +++ b/core/modules/media_library/js/plugins/drupalmedialibrary/plugin.js @@ -0,0 +1,45 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function (Drupal, CKEDITOR) { + + "use strict"; + + CKEDITOR.plugins.add('drupalmedialibrary', { + requires: 'drupalmedia', + + beforeInit: function beforeInit(editor) { + editor.addCommand('drupalmedialibrary', { + allowedContent: 'drupal-media[!data-entity-type,!data-entity-uuid,data-align,data-caption,alt,title]', + requiredContent: 'drupal-media[data-entity-type,data-entity-uuid]', + modes: { wysiwyg: 1 }, + canUndo: true, + exec: function exec(editor) { + var saveCallback = function saveCallback(values) { + editor.fire('saveSnapshot'); + var mediaElement = editor.document.createElement('drupal-media'); + var attributes = values.attributes; + for (var key in attributes) { + mediaElement.setAttribute(key, attributes[key]); + } + editor.insertHtml(mediaElement.getOuterHtml()); + editor.fire('saveSnapshot'); + }; + + Drupal.ckeditor.openDialog(editor, editor.config.DrupalMediaLibrary_url, {}, saveCallback, editor.config.DrupalMediaLibrary_dialogOptions); + } + }); + + if (editor.ui.addButton) { + editor.ui.addButton('DrupalMediaLibrary', { + label: Drupal.t('Insert from Media Library'), + command: 'drupalmedialibrary' + }); + } + } + }); +})(Drupal, CKEDITOR); \ No newline at end of file diff --git a/core/modules/media_library/media_library.module b/core/modules/media_library/media_library.module index f0c8186d9d..c54dd45939 100644 --- a/core/modules/media_library/media_library.module +++ b/core/modules/media_library/media_library.module @@ -26,6 +26,7 @@ use Drupal\views\Form\ViewsForm; use Drupal\views\Plugin\views\cache\CachePluginBase; use Drupal\views\ViewExecutable; +use Drupal\Core\StringTranslation\TranslatableMarkup; /** * Implements hook_help(). @@ -342,3 +343,68 @@ function _media_library_configure_view_display(MediaTypeInterface $type) { ]); return (bool) $display->save(); } + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function media_library_form_filter_format_edit_form_alter(array &$form, FormStateInterface $form_state, $form_id) { + // Add an additional validate callback so so we can make sure media_embed + // filter enabled if DrupalMediaLibrary button is enabled. + $form['#validate'][] = 'media_library_filter_format_edit_form_validate'; +} + +/** + * Implements hook_form_FORM_ID_alter(). + */ +function media_library_form_filter_format_add_form_alter(array &$form, FormStateInterface $form_state, $form_id) { + // Add an additional validate callback so so we can make sure media_embed + // filter enabled if DrupalMediaLibrary button is enabled. + $form['#validate'][] = 'media_library_filter_format_edit_form_validate'; +} + +/** + * Validate callback to require media_embed filter for DrupalMediaLibrary button. + */ +function media_library_filter_format_edit_form_validate($form, FormStateInterface $form_state) { + + if ($form_state->getTriggeringElement()['#name'] !== 'op') { + return; + } + + $button_group_path = [ + 'editor', + 'settings', + 'toolbar', + 'button_groups', + ]; + + if ($button_groups = $form_state->getValue($button_group_path)) { + $buttons = []; + $button_groups = json_decode($button_groups, TRUE); + if (!empty($button_groups[0])) { + foreach ($button_groups[0] as $button_group) { + foreach ($button_group['items'] as $item) { + $buttons[] = $item; + } + } + } + + // If DrupalMediaLibrary is enabled, but media_embed filter is disabled, + // set a form error. + if (in_array('DrupalMediaLibrary', $buttons, TRUE)) { + $media_embed_enabled = $form_state->getValue([ + 'filters', + 'media_embed', + 'status', + ]); + + if (!$media_embed_enabled) { + $error_message = new TranslatableMarkup('The %media-embed-filter-label filter must be enabled to use the %drupal-media-library-button button.', [ + '%media-embed-filter-label' => new TranslatableMarkup('Embed media'), + '%drupal-media-library-button' => new TranslatableMarkup('Insert from Media Library'), + ]); + $form_state->setErrorByName('filters', $error_message); + } + } + } +} diff --git a/core/modules/media_library/media_library.services.yml b/core/modules/media_library/media_library.services.yml index b2d06643b5..ff50eeb42e 100644 --- a/core/modules/media_library/media_library.services.yml +++ b/core/modules/media_library/media_library.services.yml @@ -13,3 +13,5 @@ services: media_library.opener.field_widget: class: Drupal\media_library\MediaLibraryFieldWidgetOpener arguments: ['@entity_type.manager'] + media_library.opener.editor: + class: Drupal\media_library\MediaLibraryEditorOpener diff --git a/core/modules/media_library/src/MediaLibraryEditorOpener.php b/core/modules/media_library/src/MediaLibraryEditorOpener.php new file mode 100644 index 0000000000..f7c9cdfe1d --- /dev/null +++ b/core/modules/media_library/src/MediaLibraryEditorOpener.php @@ -0,0 +1,49 @@ +getOpenerParameters()['filter_format_id']; + $filter_format = FilterFormat::load($filter_format_id); + $media_embed = $filter_format->filters('media_embed'); + return $filter_format->access('use', $account, TRUE) + ->andIf(AccessResult::allowedIf($media_embed->status === TRUE)); + } + + /** + * {@inheritdoc} + */ + public function getSelectionResponse(MediaLibraryState $state, array $selected_ids) { + $selected_media = Media::load(reset($selected_ids)); + + $response = new AjaxResponse(); + $values = [ + 'attributes' => [ + 'data-entity-type' => 'media', + 'data-entity-uuid' => $selected_media->uuid(), + 'data-align' => 'center', + ], + ]; + $response->addCommand(new EditorDialogSave($values)); + + return $response; + } + +} diff --git a/core/modules/media_library/src/Plugin/CKEditorPlugin/DrupalMediaLibrary.php b/core/modules/media_library/src/Plugin/CKEditorPlugin/DrupalMediaLibrary.php new file mode 100644 index 0000000000..be8f00b890 --- /dev/null +++ b/core/modules/media_library/src/Plugin/CKEditorPlugin/DrupalMediaLibrary.php @@ -0,0 +1,129 @@ +moduleExtensionList = $extension_list_module; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('extension.list.module') + ); + } + + /** + * {@inheritdoc} + */ + public function isInternal() { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function getDependencies(Editor $editor) { + return [ + 'drupalmedia', + ]; + } + + /** + * {@inheritdoc} + */ + public function getLibraries(Editor $editor) { + return [ + 'editor/drupal.editor.dialog', + ]; + } + + /** + * {@inheritdoc} + */ + public function getFile() { + return $this->moduleExtensionList->getPath('media_library') . '/js/plugins/drupalmedialibrary/plugin.js'; + } + + /** + * {@inheritdoc} + */ + public function getConfig(Editor $editor) { + $media_type_ids = array_keys(MediaType::loadMultiple()); + + $state = MediaLibraryState::create( + 'media_library.opener.editor', + $media_type_ids, + in_array('image', $media_type_ids, TRUE) ? 'image' : reset($media_type_ids), + 1, + ['filter_format_id' => $editor->getFilterFormat()->id()] + ); + + return [ + 'DrupalMediaLibrary_url' => Url::fromRoute('media_library.ui') + ->setOption('query', $state->all()) + ->toString(TRUE) + ->getGeneratedUrl(), + 'DrupalMediaLibrary_dialogOptions' => MediaLibraryUiBuilder::dialogOptions(), + ]; + } + + /** + * {@inheritdoc} + */ + public function getButtons() { + return [ + 'DrupalMediaLibrary' => [ + 'label' => $this->t('Insert from Media Library'), + // @todo: new icon! + 'image' => '', + ], + ]; + } + +} diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/DrupalMediaLibraryTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/DrupalMediaLibraryTest.php new file mode 100644 index 0000000000..cf39e34654 --- /dev/null +++ b/core/modules/media_library/tests/src/FunctionalJavascript/DrupalMediaLibraryTest.php @@ -0,0 +1,268 @@ + 'test_format', + 'name' => 'Test format', + 'filters' => [ + 'media_embed' => ['status' => TRUE], + ], + ])->save(); + Editor::create([ + 'editor' => 'ckeditor', + 'format' => 'test_format', + 'settings' => [ + 'toolbar' => [ + 'rows' => [ + [ + [ + 'name' => 'Embeds', + 'items' => [ + 'Source', + 'DrupalMediaLibrary', + 'Undo', + 'Redo', + ], + ], + ], + ], + ], + ], + ])->save(); + + $this->drupalCreateContentType(['type' => 'blog']); + + // Note that media_install() grants 'view media' to all users by default. + $this->adminUser = $this->drupalCreateUser([ + 'use text format test_format', + 'access media overview', + 'create blog content', + ]); + + // Create a sample media entity to be embedded. + $this->createMediaType('image', ['id' => 'image']); + File::create([ + 'uri' => $this->getTestFiles('image')[0]->uri, + ])->save(); + $this->media = Media::create([ + 'bundle' => 'image', + 'name' => 'Fear is the mind-killer', + 'field_media_image' => [ + [ + 'target_id' => 1, + 'alt' => 'default alt', + 'title' => 'default title', + ], + ], + ]); + $this->media->save(); + + $this->drupalLogin($this->adminUser); + } + + /** + * Tests that media_embed filter is required to enable the DrupalMediaLibrary + * button. + */ + public function testFormatValidation() { + $user = $this->drupalCreateUser([ + 'access administration pages', + 'administer site configuration', + 'administer filters', + ]); + + $this->drupalLogin($user); + $this->drupalGet('/admin/config/content/formats/manage/test_format'); + + $assert_session = $this->assertSession(); + $media_embed_filter = $assert_session->fieldExists('filters[media_embed][status]'); + $this->assertNotEmpty($media_embed_filter); + $media_embed_filter->uncheck(); + $assert_session->buttonExists('Save configuration')->press(); + $assert_session->pageTextContains('The Embed media filter must be enabled to use the Insert from Media Library button.'); + $media_embed_filter = $assert_session->fieldExists('filters[media_embed][status]'); + $this->assertNotEmpty($media_embed_filter); + $media_embed_filter->check(); + $assert_session->buttonExists('Save configuration')->press(); + $assert_session->pageTextContains('The text format Test format has been updated.'); + } + + /** + * Tests using DrupalMediaLibrary button to insert media embed into CKEditor. + */ + public function testCKEditorIntegration() { + $this->drupalGet('/node/add/blog'); + $this->pressEditorButton('drupalmedialibrary'); + $assert_session = $this->assertSession(); + $modal = $assert_session->waitForId('drupal-modal'); + $this->assertNotEmpty($modal); + $item = $assert_session->elementExists('xpath', '(//div[contains(@class,"media-library-item")])[1]'); + $this->assertNotEmpty($item); + $item->click(); + $assert_session->elementExists('css', 'button.media-library-select.button.button--primary')->click(); + $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + $embed = $assert_session->waitForElementVisible('css', '.media-embed-widget drupal-media .media-embed', 1000); + $this->assertNotEmpty($embed); + // @todo inserting media embed should enable undo. + // $this->assertEditorButtonEnabled('undo'); + $this->pressEditorButton('source'); + $source = $this->assertSession()->elementExists('css', 'textarea.cke_source'); + $value = $source->getValue(); + $dom = Html::load($value); + $xpath = new \DOMXPath($dom); + $drupal_media = $xpath->query('//drupal-media')[0]; + $expected_attributes = [ + 'data-entity-type' => 'media', + 'data-entity-uuid' => $this->media->uuid(), + 'data-align' => 'center', + ]; + foreach ($expected_attributes as $name => $expected) { + $this->assertSame($expected, $drupal_media->getAttribute($name)); + } + $this->pressEditorButton('source'); + $this->assignNameToCkeditorIframe(); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.media-embed-widget drupal-media .media-embed', 1000)); + $this->assertEditorButtonEnabled('undo'); + $this->pressEditorButton('undo'); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertEmpty($assert_session->waitForElementVisible('css', '.media-embed-widget drupal-media .media-embed', 1000)); + $this->assertEditorButtonDisabled('undo'); + $this->pressEditorButton('redo'); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.media-embed-widget drupal-media .media-embed', 1000)); + $this->assertEditorButtonEnabled('undo'); + } + + /** + * Tests MediaLibraryEditorOpener::checkAccess(). + * + * @param bool $media_embed_enabled + * Whether to test with media_embed filter enabled on the text format. + * @param bool $can_use_format + * Whether the logged in user is allowed to use the text format. + * + * @covers ::checkAccess + * @dataProvider openerAccessProvider + */ + public function testMediaLibraryEditorOpenerAccess($media_embed_enabled, $can_use_format) { + + $format = FilterFormat::create([ + 'format' => $this->randomMachineName(), + 'name' => $this->randomString(), + 'filters' => [ + 'media_embed' => ['status' => $media_embed_enabled], + ], + ]); + $format->save(); + + if ($can_use_format) { + $user = $this->drupalCreateUser([ + 'access media overview', + $format->getPermissionName(), + ]); + } + else { + $user = $this->drupalCreateUser([ + 'access media overview', + ]); + } + $this->drupalLogin($user); + + $state = MediaLibraryState::create( + 'media_library.opener.editor', + ['image'], + 'image', + 1, + ['filter_format_id' => $format->id()] + ); + $url_options = ['query' => $state->all()]; + $this->drupalGet('media-library', $url_options); + + $assert_session = $this->assertSession(); + + if ($media_embed_enabled && $can_use_format) { + $item = $assert_session->elementExists('xpath', '(//div[contains(@class,"media-library-item")])[1]'); + $this->assertNotEmpty($item); + } + else { + $assert_session->responseContains('Access denied'); + $assert_session->responseContains('You are not authorized to access this page.'); + } + } + + /** + * Data Provider for ::testMediaLibraryEditorOpenerAccess. + */ + public function openerAccessProvider() { + return [ + 'media_embed enabled on format' => [ + TRUE, + TRUE, + ], + 'media_embed disabled on format' => [ + FALSE, + TRUE, + ], + 'media_embed enabled, user lacks access to format' => [ + TRUE, + FALSE, + ], + ]; + } + +}