diff --git a/core/modules/ckeditor/tests/src/Traits/CKEditorTestTrait.php b/core/modules/ckeditor/tests/src/Traits/CKEditorTestTrait.php new file mode 100644 index 0000000000..58e539bfcf --- /dev/null +++ b/core/modules/ckeditor/tests/src/Traits/CKEditorTestTrait.php @@ -0,0 +1,100 @@ +getSession()->wait($timeout, $condition); + } + + /** + * Assigns a name to the CKEditor iframe. + * + * @see \Behat\Mink\Session::switchToIFrame() + */ + protected function assignNameToCkeditorIframe() { + $javascript = <<getSession()->evaluateScript($javascript); + } + + /** + * Clicks a CKEditor button. + * + * @param string $name + * The name of the button, such as drupalink, source, etc. + */ + protected function pressEditorButton($name) { + $this->getEditorButton($name)->click(); + } + + + /** + * Waits for a CKEditor button and returns it when available and visible. + * + * @param string $name + * The name of the button, such as drupalink, source, etc. + * + * @return \Behat\Mink\Element\NodeElement|null + * The page element node if found, NULL if not. + */ + protected function getEditorButton($name) { + $this->getSession()->switchToIFrame(); + $button = $this->assertSession()->waitForElementVisible('css', 'a.cke_button__' . $name); + $this->assertNotEmpty($button); + + return $button; + } + + /** + * Asserts a CKEditor button is disabled. + * + * @param string $name + * The name of the button, such as `drupallink`, `source`, etc. + */ + protected function assertEditorButtonDisabled($name) { + $button = $this->getEditorButton($name); + $this->assertTrue($button->hasClass('cke_button_disabled')); + $this->assertSame('true', $button->getAttribute('aria-disabled')); + } + + /** + * Asserts a CKEditor button is enabled. + * + * @param string $name + * The name of the button, such as `drupallink`, `source`, etc. + */ + protected function assertEditorButtonEnabled($name) { + $button = $this->getEditorButton($name); + $this->assertFalse($button->hasClass('cke_button_disabled')); + $this->assertSame('false', $button->getAttribute('aria-disabled')); + } + +} diff --git a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php index e311a14993..d0e7588697 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php @@ -9,6 +9,7 @@ use Drupal\filter\Entity\FilterFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\media\Entity\Media; +use Drupal\Tests\ckeditor\Traits\CKEditorTestTrait; use Drupal\Tests\media\Traits\MediaTypeCreationTrait; use Drupal\Tests\TestFileCreationTrait; @@ -18,6 +19,7 @@ */ class CKEditorIntegrationTest extends WebDriverTestBase { + use CKEditorTestTrait; use MediaTypeCreationTrait; use TestFileCreationTrait; @@ -669,20 +671,6 @@ protected function setCaption($text) { } /** - * Assigns a name to the CKEditor iframe. - * - * @see \Behat\Mink\Session::switchToIFrame() - */ - protected function assignNameToCkeditorIframe() { - $javascript = <<getSession()->evaluateScript($javascript); - } - - /** * Assigns a name to the CKEditor context menu iframe. * * Note that this iframe doesn't appear until context menu appears. @@ -699,82 +687,6 @@ protected function assignNameToCkeditorPanelIframe() { } /** - * Clicks a CKEditor button. - * - * @param string $name - * The name of the button, such as drupalink, source, etc. - */ - protected function pressEditorButton($name) { - $this->getSession()->switchToIFrame(); - $button = $this->assertSession()->waitForElementVisible('css', 'a.cke_button__' . $name); - $this->assertNotEmpty($button); - $button->click(); - } - - /** - * Waits for a CKEditor button and returns it when available and visible. - * - * @param string $name - * The name of the button, such as drupalink, source, etc. - * - * @return \Behat\Mink\Element\NodeElement|null - * The page element node if found, NULL if not. - */ - protected function getEditorButton($name) { - $this->getSession()->switchToIFrame(); - $button = $this->assertSession()->waitForElementVisible('css', 'a.cke_button__' . $name); - $this->assertNotEmpty($button); - - return $button; - } - - /** - * Asserts a CKEditor button is disabled. - * - * @param string $name - * The name of the button, such as `drupallink`, `source`, etc. - */ - protected function assertEditorButtonDisabled($name) { - $button = $this->getEditorButton($name); - $this->assertTrue($button->hasClass('cke_button_disabled')); - $this->assertSame('true', $button->getAttribute('aria-disabled')); - } - - /** - * Asserts a CKEditor button is enabled. - * - * @param string $name - * The name of the button, such as `drupallink`, `source`, etc. - */ - protected function assertEditorButtonEnabled($name) { - $button = $this->getEditorButton($name); - $this->assertFalse($button->hasClass('cke_button_disabled')); - $this->assertSame('false', $button->getAttribute('aria-disabled')); - } - - /** - * Waits for CKEditor to initialize. - * - * @param string $instance_id - * The CKEditor instance ID. - * @param int $timeout - * (optional) Timeout in milliseconds, defaults to 10000. - */ - protected function waitForEditor($instance_id = 'edit-body-0-value', $timeout = 10000) { - $condition = <<getSession()->wait($timeout, $condition); - } - - /** * Opens the context menu for the currently selected widget. * * @param string $instance_id 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..e088ffa398 --- /dev/null +++ b/core/modules/media_library/js/plugins/drupalmedialibrary/plugin.es6.js @@ -0,0 +1,53 @@ +/** + * @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 }, + // There is an edge case related to the undo functionality that will + // be resolved in https://www.drupal.org/project/drupal/issues/3073294. + 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..860035e72b 100644 --- a/core/modules/media_library/media_library.module +++ b/core/modules/media_library/media_library.module @@ -26,6 +26,8 @@ use Drupal\views\Form\ViewsForm; use Drupal\views\Plugin\views\cache\CachePluginBase; use Drupal\views\ViewExecutable; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Component\Serialization\Json; /** * Implements hook_help(). @@ -342,3 +344,73 @@ 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 ensure the media_embed + // filter is enabled when the 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 ensure the media_embed + // filter is enabled when the DrupalMediaLibrary button is enabled. + $form['#validate'][] = 'media_library_filter_format_edit_form_validate'; +} + +/** + * Validate callback to ensure the DrupalMediaLibrary button can work correctly. + */ +function media_library_filter_format_edit_form_validate($form, FormStateInterface $form_state) { + if ($form_state->getTriggeringElement()['#name'] !== 'op') { + return; + } + + // The "DrupalMediaLibrary" button is for the CKEditor text editor. + if ($form_state->getValue(['editor', 'editor']) !== 'ckeditor') { + 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); + + foreach ($button_groups as $button_row) { + foreach ($button_row as $button_group) { + $buttons = array_merge($buttons, array_values($button_group['items'])); + } + } + + $get_filter_label = function ($filter_plugin_id) use ($form) { + return (string) $form['filters']['order'][$filter_plugin_id]['filter']['#markup']; + }; + + 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' => $get_filter_label('media_embed'), + '%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..eaad55fcf5 100644 --- a/core/modules/media_library/media_library.services.yml +++ b/core/modules/media_library/media_library.services.yml @@ -13,3 +13,6 @@ services: media_library.opener.field_widget: class: Drupal\media_library\MediaLibraryFieldWidgetOpener arguments: ['@entity_type.manager'] + media_library.opener.editor: + class: Drupal\media_library\MediaLibraryEditorOpener + arguments: ['@entity_type.manager'] diff --git a/core/modules/media_library/src/MediaLibraryEditorOpener.php b/core/modules/media_library/src/MediaLibraryEditorOpener.php new file mode 100644 index 0000000000..755eb6d2a0 --- /dev/null +++ b/core/modules/media_library/src/MediaLibraryEditorOpener.php @@ -0,0 +1,78 @@ +filterStorage = $entity_type_manager->getStorage('filter_format'); + $this->mediaStorage = $entity_type_manager->getStorage('media'); + } + + /** + * {@inheritdoc} + */ + public function checkAccess(MediaLibraryState $state, AccountInterface $account) { + $filter_format_id = $state->getOpenerParameters()['filter_format_id']; + $filter_format = $this->filterStorage->load($filter_format_id); + if (empty($filter_format)) { + return AccessResult::forbidden() + ->addCacheTags(['filter_format_list']) + ->setReason("The text format '$filter_format_id' could not be loaded."); + } + $filters = $filter_format->filters(); + return $filter_format->access('use', $account, TRUE) + ->andIf(AccessResult::allowedIf($filters->has('media_embed') && $filters->get('media_embed')->status === TRUE)); + } + + /** + * {@inheritdoc} + */ + public function getSelectionResponse(MediaLibraryState $state, array $selected_ids) { + $selected_media = $this->mediaStorage->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..54e50a6cd7 --- /dev/null +++ b/core/modules/media_library/src/Plugin/CKEditorPlugin/DrupalMediaLibrary.php @@ -0,0 +1,141 @@ +moduleExtensionList = $extension_list_module; + $this->mediaTypeStorage = $entity_type_manager->getStorage('media_type'); + } + + /** + * {@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'), + $container->get('entity_type.manager') + ); + } + + /** + * {@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 = $this->mediaTypeStorage + ->getQuery() + ->execute(); + $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/CKEditorIntegrationTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/CKEditorIntegrationTest.php new file mode 100644 index 0000000000..9ba386f30c --- /dev/null +++ b/core/modules/media_library/tests/src/FunctionalJavascript/CKEditorIntegrationTest.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' => 'Main', + 'items' => [ + 'Source', + 'Undo', + 'Redo', + ], + ], + ], + [ + [ + 'name' => 'Embeds', + 'items' => [ + 'DrupalMediaLibrary', + ], + ], + ], + ], + ], + ], + ])->save(); + + $this->drupalCreateContentType(['type' => 'blog']); + + // Note that media_install() grants 'view media' to all users by default. + $this->user = $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->user); + } + + /** + * Tests that media_embed filter is required to enable the DrupalMediaLibrary + * button. + */ + public function testConfigurationValidation() { + $admin_user = $this->drupalCreateUser([ + 'access administration pages', + 'administer site configuration', + 'administer filters', + ]); + $this->drupalLogin($admin_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 embed media into CKEditor. + */ + public function testButton() { + $this->drupalGet('/node/add/blog'); + $this->waitForEditor(); + $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', '.cke_widget_drupalmedia drupal-media .media', 2000); + $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', '.cke_widget_drupalmedia drupal-media .media', 1000)); + $this->assertEditorButtonEnabled('undo'); + $this->pressEditorButton('undo'); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertEmpty($assert_session->waitForElementVisible('css', '.cke_widget_drupalmedia drupal-media .media', 1000)); + $this->assertEditorButtonDisabled('undo'); + $this->pressEditorButton('redo'); + $this->getSession()->switchToIFrame('ckeditor'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.cke_widget_drupalmedia drupal-media .media', 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. + * + * @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(); + + $permissions = [ + 'access media overview', + ]; + if ($can_use_format) { + $permissions[] = $format->getPermissionName(); + } + $this->drupalLogin($this->drupalCreateUser($permissions)); + + $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 filter enabled' => [ + TRUE, + TRUE, + ], + 'media_embed filter disabled' => [ + FALSE, + TRUE, + ], + 'media_embed filter enabled, user not allowed to use text format' => [ + TRUE, + FALSE, + ], + ]; + } + +}