Commit 6280478a authored by webchick's avatar webchick
Browse files

Issue #2994699 by Wim Leers, oknate, ckrina, legovaer, phenaproxima, webchick,...

Issue #2994699 by Wim Leers, oknate, ckrina, legovaer, phenaproxima, webchick, seanB: Create a CKEditor plugin to select and embed a media item from the Media Library
parent e47700f7
<?php
namespace Drupal\Tests\ckeditor\Traits;
/**
* Provides methods to test CKEditor.
*
* This trait is meant to be used only by functional JavaScript test classes.
*/
trait CKEditorTestTrait {
/**
* 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 = <<<JS
(function() {
return (
typeof CKEDITOR !== 'undefined'
&& typeof CKEDITOR.instances["$instance_id"] !== 'undefined'
&& CKEDITOR.instances["$instance_id"].instanceReady
);
}());
JS;
$this->getSession()->wait($timeout, $condition);
}
/**
* Assigns a name to the CKEditor iframe.
*
* @see \Behat\Mink\Session::switchToIFrame()
*/
protected function assignNameToCkeditorIframe() {
$javascript = <<<JS
(function(){
document.getElementsByClassName('cke_wysiwyg_frame')[0].id = 'ckeditor';
})()
JS;
$this->getSession()->evaluateScript($javascript);
}
/**
* Clicks a CKEditor button.
*
* @param string $name
* The name of the button, such as `drupallink`, `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 `drupallink`, `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'));
}
}
......@@ -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;
......@@ -668,20 +670,6 @@ protected function setCaption($text) {
$this->getSession()->executeScript($select_and_edit_caption);
}
/**
* Assigns a name to the CKEditor iframe.
*
* @see \Behat\Mink\Session::switchToIFrame()
*/
protected function assignNameToCkeditorIframe() {
$javascript = <<<JS
(function(){
document.getElementsByClassName('cke_wysiwyg_frame')[0].id = 'ckeditor';
})()
JS;
$this->getSession()->evaluateScript($javascript);
}
/**
* Assigns a name to the CKEditor context menu iframe.
*
......@@ -698,82 +686,6 @@ protected function assignNameToCkeditorPanelIframe() {
$this->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->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 = <<<JS
(function() {
return (
typeof CKEDITOR !== 'undefined'
&& typeof CKEDITOR.instances["$instance_id"] !== 'undefined'
&& CKEDITOR.instances["$instance_id"].instanceReady
);
}());
JS;
$this->getSession()->wait($timeout, $condition);
}
/**
* Opens the context menu for the currently selected widget.
*
......
......@@ -2,6 +2,14 @@
* @file media_library.module.css
*/
/**
* By default, the dialog is too narrow to be usable.
* @see Drupal.ckeditor.openDialog()
*/
.ui-dialog--narrow.media-library-widget-modal {
max-width: 75%;
}
.media-library-wrapper {
display: flex;
}
......
/**
* @file
* Drupal Media Library plugin.
*/
(function(Drupal, CKEDITOR) {
CKEDITOR.plugins.add('drupalmedialibrary', {
requires: 'drupalmedia',
icons: 'drupalmedialibrary',
hidpi: true,
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(editor) {
const saveCallback = function(values) {
editor.fire('saveSnapshot');
const mediaElement = editor.document.createElement('drupal-media');
const attributes = values.attributes;
Object.keys(attributes).forEach(key => {
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);
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, CKEDITOR) {
CKEDITOR.plugins.add('drupalmedialibrary', {
requires: 'drupalmedia',
icons: 'drupalmedialibrary',
hidpi: true,
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;
Object.keys(attributes).forEach(function (key) {
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
......@@ -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);
}
}
}
}
......@@ -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']
<?php
namespace Drupal\media_library;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\editor\Ajax\EditorDialogSave;
/**
* The media library opener for text editors.
*
* @see \Drupal\media_library\Plugin\CKEditorPlugin\DrupalMediaLibrary
*
* @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 MediaLibraryEditorOpener implements MediaLibraryOpenerInterface {
/**
* The text format entity storage.
*
* @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface
*/
protected $filterStorage;
/**
* The media storage.
*
* @var \Drupal\Core\Entity\ContentEntityStorageInterface
*/
protected $mediaStorage;
/**
* The MediaLibraryEditorOpener constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->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;
}
}
<?php
namespace Drupal\media_library\Plugin\CKEditorPlugin;
use Drupal\ckeditor\CKEditorPluginBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Url;
use Drupal\editor\Entity\Editor;
use Drupal\media_library\MediaLibraryState;
use Drupal\media_library\MediaLibraryUiBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines the "drupalmedialibrary" plugin.
*
* @CKEditorPlugin(
* id = "drupalmedialibrary",
* label = @Translation("Embed media from the Media Library"),
* )
*
* @internal
* Plugin classes are internal.
*/
class DrupalMediaLibrary extends CKEditorPluginBase implements ContainerFactoryPluginInterface {
/**
* The module extension list.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected $moduleExtensionList;
/**
* The media type entity storage.
*
* @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface
*/
protected $mediaTypeStorage;
/**
* Constructs a new DrupalMediaLibrary plugin object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param array $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module
* The module extension list.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(array $configuration, $plugin_id, array $plugin_definition, ModuleExtensionList $extension_list_module, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->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}