Issue #2994696 by oknate, Wim Leers, phenaproxima, starshaped, lauriii, andrewmacpherson, ckrina, a.milkovsky, John Pitcairn, artis, cs_shadow, duncan.moo, Berdir, JamesK, Ambidex, BartK, idflood, Dave Reid, CTaPByK, Reinmar, kmoll, webflo, thenchev, wrd, yched, kkrzton, slashrsm, root_brute, Ruuds, MobliMic, ls206, marcoscano, pixelmord, narnua: Render embedded media items in CKEditor

merge-requests/55/head
effulgentsia 2019-08-06 23:11:04 -07:00
parent 2f154624fd
commit f5b124ab5e
27 changed files with 1941 additions and 50 deletions

View File

@ -440,6 +440,7 @@
},
"ignoreFiles": [
"assets/vendor/**/*.css",
"tests/Drupal/Tests/Core/Asset/css_test_files/**/*.css"
"tests/Drupal/Tests/Core/Asset/css_test_files/**/*.css",
"modules/media/css/plugins/drupalmedia/ckeditor.drupalmedia.css"
]
}

View File

@ -47,6 +47,8 @@
return;
}
CKEDITOR.plugins.drupallink.registerLinkableWidget('image');
// Override default behaviour of 'drupalunlink' command.
editor.getCommand('drupalunlink').on('exec', function(evt) {
const widget = getFocusedWidget(editor);

View File

@ -21,6 +21,8 @@
return;
}
CKEDITOR.plugins.drupallink.registerLinkableWidget('image');
editor.getCommand('drupalunlink').on('exec', function (evt) {
var widget = getFocusedWidget(editor);

View File

@ -61,6 +61,35 @@
};
}
const registeredLinkableWidgets = [];
/**
* Registers a widget name as linkable.
*
* @param {string} widgetName
* The name of the widget to register as linkable.
*/
function registerLinkableWidget(widgetName) {
registeredLinkableWidgets.push(widgetName);
}
/**
* Gets the focused widget, if one of the registered linkable widget names.
*
* @param {CKEDITOR.editor} editor
* A CKEditor instance.
*
* @return {?CKEDITOR.plugins.widget}
* The focused linkable widget instance, or null.
*/
function getFocusedLinkableWidget(editor) {
const widget = editor.widgets.focused;
if (widget && registeredLinkableWidgets.indexOf(widget.name) !== -1) {
return widget;
}
return null;
}
/**
* Get the surrounding link element of current selection.
*
@ -121,9 +150,7 @@
modes: { wysiwyg: 1 },
canUndo: true,
exec(editor) {
const drupalImageUtils = CKEDITOR.plugins.drupalimage;
const focusedImageWidget =
drupalImageUtils && drupalImageUtils.getFocusedWidget(editor);
const focusedLinkableWidget = getFocusedLinkableWidget(editor);
let linkElement = getSelectedLink(editor);
// Set existing values based on selected element.
@ -133,20 +160,22 @@
}
// Or, if an image widget is focused, we're editing a link wrapping
// an image widget.
else if (focusedImageWidget && focusedImageWidget.data.link) {
existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link);
else if (focusedLinkableWidget && focusedLinkableWidget.data.link) {
existingValues = CKEDITOR.tools.clone(
focusedLinkableWidget.data.link,
);
}
// Prepare a save callback to be used upon saving the dialog.
const saveCallback = function(returnValues) {
// If an image widget is focused, we're not editing an independent
// link, but we're wrapping an image widget in a link.
if (focusedImageWidget) {
focusedImageWidget.setData(
if (focusedLinkableWidget) {
focusedLinkableWidget.setData(
'link',
CKEDITOR.tools.extend(
returnValues.attributes,
focusedImageWidget.data.link,
focusedLinkableWidget.data.link,
),
);
editor.fire('saveSnapshot');
@ -330,5 +359,6 @@
CKEDITOR.plugins.drupallink = {
parseLinkAttributes: parseAttributes,
getLinkAttributes: getAttributes,
registerLinkableWidget,
};
})(jQuery, Drupal, drupalSettings, CKEDITOR);

View File

@ -49,6 +49,20 @@
};
}
var registeredLinkableWidgets = [];
function registerLinkableWidget(widgetName) {
registeredLinkableWidgets.push(widgetName);
}
function getFocusedLinkableWidget(editor) {
var widget = editor.widgets.focused;
if (widget && registeredLinkableWidgets.indexOf(widget.name) !== -1) {
return widget;
}
return null;
}
function getSelectedLink(editor) {
var selection = editor.getSelection();
var selectedElement = selection.getSelectedElement();
@ -88,20 +102,19 @@
modes: { wysiwyg: 1 },
canUndo: true,
exec: function exec(editor) {
var drupalImageUtils = CKEDITOR.plugins.drupalimage;
var focusedImageWidget = drupalImageUtils && drupalImageUtils.getFocusedWidget(editor);
var focusedLinkableWidget = getFocusedLinkableWidget(editor);
var linkElement = getSelectedLink(editor);
var existingValues = {};
if (linkElement && linkElement.$) {
existingValues = parseAttributes(editor, linkElement);
} else if (focusedImageWidget && focusedImageWidget.data.link) {
existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link);
} else if (focusedLinkableWidget && focusedLinkableWidget.data.link) {
existingValues = CKEDITOR.tools.clone(focusedLinkableWidget.data.link);
}
var saveCallback = function saveCallback(returnValues) {
if (focusedImageWidget) {
focusedImageWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedImageWidget.data.link));
if (focusedLinkableWidget) {
focusedLinkableWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedLinkableWidget.data.link));
editor.fire('saveSnapshot');
return;
}
@ -244,6 +257,7 @@
CKEDITOR.plugins.drupallink = {
parseLinkAttributes: parseAttributes,
getLinkAttributes: getAttributes
getLinkAttributes: getAttributes,
registerLinkableWidget: registerLinkableWidget
};
})(jQuery, Drupal, drupalSettings, CKEDITOR);

View File

@ -0,0 +1,20 @@
/**
* @file
* Media Embed filter: default styling for media embed errors.
*/
/**
* The caption filter's styling overrides ours, so add a more specific selector
* to account for that.
*/
.media-embed-error,
.caption > .media-embed-error {
max-width: 200px;
padding: 100px 20px 20px;
text-align: center;
background-color: #ebebeb;
background-image: url(../images/icons/no-thumbnail.png);
background-repeat: no-repeat;
background-position: center top;
background-size: 100px 100px;
}

View File

@ -0,0 +1,22 @@
/**
* @file
* Media embed: overrides to make focus styles and alignment work in CKEditor.
*/
/**
* Allow the drupal-media element's width to collapse to the size of its
* contents so that the outline has no extra white space (margin). This
* emulates the image2 plugin's styles inherited by the drupallink CKEditor
* plugin.
*/
drupal-media {
display: inline-block;
}
/**
* For center alignment, take advantage of drupal-media's inline-block
* display and center it as if it were text.
*/
.cke_widget_drupalmedia.align-center {
text-align: center;
}

View File

@ -0,0 +1,302 @@
/**
* @file
* Drupal Media embed plugin.
*/
(function(jQuery, Drupal, CKEDITOR) {
/**
* Gets the focused widget, if of the type specific for this plugin.
*
* @param {CKEDITOR.editor} editor
* A CKEditor instance.
*
* @return {?CKEDITOR.plugins.widget}
* The focused drupalmedia widget instance, or null.
*/
function getFocusedWidget(editor) {
const widget = editor.widgets.focused;
if (widget && widget.name === 'drupalmedia') {
return widget;
}
return null;
}
/**
* Makes embedded items linkable by integrating with the drupallink plugin.
*
* @param {CKEDITOR.editor} editor
* A CKEditor instance.
*/
function linkCommandIntegrator(editor) {
if (!editor.plugins.drupallink) {
return;
}
CKEDITOR.plugins.drupallink.registerLinkableWidget('drupalmedia');
editor.getCommand('drupalunlink').on('exec', function(evt) {
const widget = getFocusedWidget(editor);
if (!widget) {
return;
}
widget.setData('link', null);
this.refresh(editor, editor.elementPath());
evt.cancel();
});
editor.getCommand('drupalunlink').on('refresh', function(evt) {
const widget = getFocusedWidget(editor);
if (!widget) {
return;
}
this.setState(
widget.data.link ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED,
);
evt.cancel();
});
// Register context menu items for editing link.
if (editor.contextMenu) {
editor.contextMenu.addListener(() => {
const widget = getFocusedWidget(editor);
if (!widget) {
return;
}
if (widget.data.link) {
return {
link: CKEDITOR.TRISTATE_OFF,
unlink: CKEDITOR.TRISTATE_OFF,
};
}
return {};
});
}
}
/**
* Themes the error displayed when the media embed preview fails.
*
* @return {string}
* An HTML string to insert in the CKEditor.
*/
Drupal.theme.mediaEmbedError = function() {
const error = Drupal.t(
'An error occurred while trying to preview the media. Please save your work and reload this page.',
);
return `<div class="media-embed-error media-embed-error--preview-error">${error}</div>`;
};
CKEDITOR.plugins.add('drupalmedia', {
requires: 'widget',
beforeInit(editor) {
// Configure CKEditor DTD for custom drupal-media element.
// @see https://www.drupal.org/node/2448449#comment-9717735
const dtd = CKEDITOR.dtd;
// Allow text within the drupal-media tag.
dtd['drupal-media'] = { '#': 1 };
// Register drupal-media element as an allowed child in each tag that can
// contain a div element and as an allowed child of the a tag.
Object.keys(dtd).forEach(tagName => {
if (dtd[tagName].div) {
dtd[tagName]['drupal-media'] = 1;
}
});
dtd.a['drupal-media'] = 1;
editor.widgets.add('drupalmedia', {
allowedContent:
'drupal-media[data-entity-type,data-entity-uuid,data-view-mode,data-align,data-caption,alt,title]',
// Minimum HTML which is required by this widget to work.
requiredContent: 'drupal-media[data-entity-type,data-entity-uuid]',
pathName: Drupal.t('Embedded media'),
editables: {
caption: {
selector: 'figcaption',
allowedContent: 'a[!href]; em strong cite code br',
pathName: Drupal.t('Caption'),
},
},
upcast(element, data) {
const attributes = element.attributes;
// This matches the behavior of the corresponding server-side text filter plugin.
if (
element.name !== 'drupal-media' ||
attributes['data-entity-type'] !== 'media' ||
attributes['data-entity-uuid'] === undefined
) {
return;
}
data.attributes = CKEDITOR.tools.copy(attributes);
data.hasCaption = data.attributes.hasOwnProperty('data-caption');
data.link = null;
if (element.parent.name === 'a') {
data.link = CKEDITOR.tools.copy(element.parent.attributes);
// Omit CKEditor-internal attributes.
Object.keys(element.parent.attributes).forEach(attrName => {
if (attrName.indexOf('data-cke-') !== -1) {
delete data.link[attrName];
}
});
}
return element;
},
destroy() {
this._tearDownDynamicEditables();
},
data(event) {
if (this._previewNeedsServerSideUpdate()) {
editor.fire('lockSnapshot');
this._tearDownDynamicEditables();
this._loadPreview(widget => {
widget._setUpDynamicEditables();
editor.fire('unlockSnapshot');
});
}
// Allow entity_embed.editor.css to respond to changes (for example in alignment).
this.element.setAttributes(this.data.attributes);
// Convert data-align attribute to class so we're not applying styles
// to data attributes.
// @todo Consider removing this in https://www.drupal.org/project/drupal/issues/3072279
if (this.data.attributes.hasOwnProperty('data-align')) {
this.element
.getParent()
.addClass(`align-${this.data.attributes['data-align']}`);
}
// Track the previous state to allow checking if preview needs
// server side update.
this.oldData = CKEDITOR.tools.clone(this.data);
},
downcast() {
const downcastElement = new CKEDITOR.htmlParser.element(
'drupal-media',
this.data.attributes,
);
if (this.data.link) {
const link = new CKEDITOR.htmlParser.element('a', this.data.link);
link.add(downcastElement);
return link;
}
return downcastElement;
},
_setUpDynamicEditables() {
// Now that the caption is available in the DOM, make it editable.
if (this.initEditable('caption', this.definition.editables.caption)) {
const captionEditable = this.editables.caption;
// @see core/modules/filter/css/filter.caption.css
// @see ckeditor_ckeditor_css_alter()
captionEditable.setAttribute(
'data-placeholder',
Drupal.t('Enter caption here'),
);
// Ensure that any changes made to the caption are persisted in the
// widget's data-caption attribute.
this.captionObserver = new MutationObserver(() => {
const mediaAttributes = CKEDITOR.tools.clone(
this.data.attributes,
);
mediaAttributes['data-caption'] = captionEditable.getData();
this.setData('attributes', mediaAttributes);
});
this.captionObserver.observe(captionEditable.$, {
characterData: true,
attributes: true,
childList: true,
subtree: true,
});
}
},
_tearDownDynamicEditables() {
// If we are watching for changes to the caption, stop doing that.
if (this.captionObserver) {
this.captionObserver.disconnect();
}
},
/**
* Determines if the preview needs to be re-rendered by the server.
*
* @returns {boolean}
*/
_previewNeedsServerSideUpdate() {
// When the widget is first loading, it of course needs to still get a preview!
if (!this.ready) {
return true;
}
return this._hashData(this.oldData) !== this._hashData(this.data);
},
/**
* Computes a hash of the data that can only be previewed by the server.
*
* @return {string}
*/
_hashData(data) {
const dataToHash = CKEDITOR.tools.clone(data);
// The caption does not need rendering.
delete dataToHash.attributes['data-caption'];
// Changed link destinations do not affect the visual preview.
if (dataToHash.link) {
delete dataToHash.link.href;
}
return JSON.stringify(dataToHash);
},
/**
* Loads an media embed preview and runs a callback after insertion.
*
* Note the absence of caching, that's because this uses a GET request (which is cacheable) and the server takes
* special care to make the responses privately cacheable (i.e. per session) in the browser.
*
* @see \Drupal\media\Controller\MediaFilterController::preview()
*
* @param {function} callback
* A callback function that will be called after the preview has
* loaded. Receives the widget instance.
*/
_loadPreview(callback) {
jQuery.get({
url: Drupal.url(`media/${editor.config.drupal.format}/preview`),
data: {
text: this.downcast().getOuterHtml(),
},
dataType: 'html',
success: previewHtml => {
this.element.setHtml(previewHtml);
callback(this);
},
error: () => {
this.element.setHtml(Drupal.theme('mediaEmbedError'));
},
});
},
});
},
afterInit(editor) {
linkCommandIntegrator(editor);
},
});
})(jQuery, Drupal, CKEDITOR);

View File

@ -0,0 +1,223 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (jQuery, Drupal, CKEDITOR) {
function getFocusedWidget(editor) {
var widget = editor.widgets.focused;
if (widget && widget.name === 'drupalmedia') {
return widget;
}
return null;
}
function linkCommandIntegrator(editor) {
if (!editor.plugins.drupallink) {
return;
}
CKEDITOR.plugins.drupallink.registerLinkableWidget('drupalmedia');
editor.getCommand('drupalunlink').on('exec', function (evt) {
var widget = getFocusedWidget(editor);
if (!widget) {
return;
}
widget.setData('link', null);
this.refresh(editor, editor.elementPath());
evt.cancel();
});
editor.getCommand('drupalunlink').on('refresh', function (evt) {
var widget = getFocusedWidget(editor);
if (!widget) {
return;
}
this.setState(widget.data.link ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED);
evt.cancel();
});
if (editor.contextMenu) {
editor.contextMenu.addListener(function () {
var widget = getFocusedWidget(editor);
if (!widget) {
return;
}
if (widget.data.link) {
return {
link: CKEDITOR.TRISTATE_OFF,
unlink: CKEDITOR.TRISTATE_OFF
};
}
return {};
});
}
}
Drupal.theme.mediaEmbedError = function () {
var error = Drupal.t('An error occurred while trying to preview the media. Please save your work and reload this page.');
return '<div class="media-embed-error media-embed-error--preview-error">' + error + '</div>';
};
CKEDITOR.plugins.add('drupalmedia', {
requires: 'widget',
beforeInit: function beforeInit(editor) {
var dtd = CKEDITOR.dtd;
dtd['drupal-media'] = { '#': 1 };
Object.keys(dtd).forEach(function (tagName) {
if (dtd[tagName].div) {
dtd[tagName]['drupal-media'] = 1;
}
});
dtd.a['drupal-media'] = 1;
editor.widgets.add('drupalmedia', {
allowedContent: 'drupal-media[data-entity-type,data-entity-uuid,data-view-mode,data-align,data-caption,alt,title]',
requiredContent: 'drupal-media[data-entity-type,data-entity-uuid]',
pathName: Drupal.t('Embedded media'),
editables: {
caption: {
selector: 'figcaption',
allowedContent: 'a[!href]; em strong cite code br',
pathName: Drupal.t('Caption')
}
},
upcast: function upcast(element, data) {
var attributes = element.attributes;
if (element.name !== 'drupal-media' || attributes['data-entity-type'] !== 'media' || attributes['data-entity-uuid'] === undefined) {
return;
}
data.attributes = CKEDITOR.tools.copy(attributes);
data.hasCaption = data.attributes.hasOwnProperty('data-caption');
data.link = null;
if (element.parent.name === 'a') {
data.link = CKEDITOR.tools.copy(element.parent.attributes);
Object.keys(element.parent.attributes).forEach(function (attrName) {
if (attrName.indexOf('data-cke-') !== -1) {
delete data.link[attrName];
}
});
}
return element;
},
destroy: function destroy() {
this._tearDownDynamicEditables();
},
data: function data(event) {
if (this._previewNeedsServerSideUpdate()) {
editor.fire('lockSnapshot');
this._tearDownDynamicEditables();
this._loadPreview(function (widget) {
widget._setUpDynamicEditables();
editor.fire('unlockSnapshot');
});
}
this.element.setAttributes(this.data.attributes);
if (this.data.attributes.hasOwnProperty('data-align')) {
this.element.getParent().addClass('align-' + this.data.attributes['data-align']);
}
this.oldData = CKEDITOR.tools.clone(this.data);
},
downcast: function downcast() {
var downcastElement = new CKEDITOR.htmlParser.element('drupal-media', this.data.attributes);
if (this.data.link) {
var link = new CKEDITOR.htmlParser.element('a', this.data.link);
link.add(downcastElement);
return link;
}
return downcastElement;
},
_setUpDynamicEditables: function _setUpDynamicEditables() {
var _this = this;
if (this.initEditable('caption', this.definition.editables.caption)) {
var captionEditable = this.editables.caption;
captionEditable.setAttribute('data-placeholder', Drupal.t('Enter caption here'));
this.captionObserver = new MutationObserver(function () {
var mediaAttributes = CKEDITOR.tools.clone(_this.data.attributes);
mediaAttributes['data-caption'] = captionEditable.getData();
_this.setData('attributes', mediaAttributes);
});
this.captionObserver.observe(captionEditable.$, {
characterData: true,
attributes: true,
childList: true,
subtree: true
});
}
},
_tearDownDynamicEditables: function _tearDownDynamicEditables() {
if (this.captionObserver) {
this.captionObserver.disconnect();
}
},
_previewNeedsServerSideUpdate: function _previewNeedsServerSideUpdate() {
if (!this.ready) {
return true;
}
return this._hashData(this.oldData) !== this._hashData(this.data);
},
_hashData: function _hashData(data) {
var dataToHash = CKEDITOR.tools.clone(data);
delete dataToHash.attributes['data-caption'];
if (dataToHash.link) {
delete dataToHash.link.href;
}
return JSON.stringify(dataToHash);
},
_loadPreview: function _loadPreview(callback) {
var _this2 = this;
jQuery.get({
url: Drupal.url('media/' + editor.config.drupal.format + '/preview'),
data: {
text: this.downcast().getOuterHtml()
},
dataType: 'html',
success: function success(previewHtml) {
_this2.element.setHtml(previewHtml);
callback(_this2);
},
error: function error() {
_this2.element.setHtml(Drupal.theme('mediaEmbedError'));
}
});
}
});
},
afterInit: function afterInit(editor) {
linkCommandIntegrator(editor);
}
});
})(jQuery, Drupal, CKEDITOR);

View File

@ -31,3 +31,9 @@ filter.caption:
css/filter.caption.css: {}
dependencies:
- filter/caption
media_embed:
version: VERSION
css:
component:
css/filter.media_embed.css: {}

View File

@ -39,3 +39,12 @@ media.settings:
_title: 'Media settings'
requirements:
_permission: 'administer media'
media.filter.preview:
path: '/media/{filter_format}/preview'
defaults:
_controller: '\Drupal\media\Controller\MediaFilterController::preview'
methods: [GET]
requirements:
_entity_access: 'filter_format.use'
_custom_access: '\Drupal\media\Controller\MediaFilterController::formatUsesMediaEmbedFilter'

View File

@ -0,0 +1,111 @@
<?php
namespace Drupal\media\Controller;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\filter\FilterFormatInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Controller which renders a preview of the provided text.
*
* @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 MediaFilterController implements ContainerInjectionInterface {
/**
* The renderer service.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs an MediaFilterController instance.
*
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer service.
*/
public function __construct(RendererInterface $renderer) {
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('renderer')
);
}
/**
* Returns a HTML response containing a preview of the text after filtering.
*
* Applies all of the given text format's filters, not just the `media_embed`
* filter, because for example `filter_align` and `filter_caption` may apply
* to it as well.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
* @param \Drupal\filter\FilterFormatInterface $filter_format
* The text format.
*
* @return \Symfony\Component\HttpFoundation\Response
* The filtered text.
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* Throws an exception if 'text' parameter is not found in the query
* string.
*
* @see \Drupal\editor\EditorController::getUntransformedText
*/
public function preview(Request $request, FilterFormatInterface $filter_format) {
$text = $request->query->get('text');
if ($text == '') {
throw new NotFoundHttpException();
}
$build = [
'#type' => 'processed_text',
'#text' => $text,
'#format' => $filter_format->id(),
];
$html = $this->renderer->renderPlain($build);
// Note that we intentionally do not use:
// - \Drupal\Core\Cache\CacheableResponse because caching it on the server
// side is wasteful, hence there is no need for cacheability metadata.
// - \Drupal\Core\Render\HtmlResponse because there is no need for
// attachments nor cacheability metadata.
return (new Response($html))
// Do not allow any intermediary to cache the response, only the end user.
->setPrivate()
// Allow the end user to cache it for up to 5 minutes.
->setMaxAge(300);
}
/**
* Checks access based on media_embed filter status on the text format.
*
* @param \Drupal\filter\FilterFormatInterface $filter_format
* The text format for which to check access.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public static function formatUsesMediaEmbedFilter(FilterFormatInterface $filter_format) {
$filters = $filter_format->filters();
return AccessResult::allowedIf($filters->has('media_embed') && $filters->get('media_embed')->status)
->addCacheableDependency($filter_format);
}
}

View File

@ -29,8 +29,9 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
* of an iframe.
*
* @internal
* This is an internal part of the oEmbed system and should only be used by
* oEmbed-related code in Drupal core.
* 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 OEmbedIframeController implements ContainerInjectionInterface {

View File

@ -0,0 +1,130 @@
<?php
namespace Drupal\media\Plugin\CKEditorPlugin;
use Drupal\ckeditor\CKEditorPluginContextualInterface;
use Drupal\ckeditor\CKEditorPluginCssInterface;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\editor\Entity\Editor;
/**
* Defines the "drupalmedia" plugin.
*
* @CKEditorPlugin(
* id = "drupalmedia",
* label = @Translation("Media Embed"),
* )
*
* @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 DrupalMedia extends PluginBase implements ContainerFactoryPluginInterface, CKEditorPluginContextualInterface, CKEditorPluginCssInterface {
/**
* The module extension list.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected $moduleExtensionList;
/**
* Constructs a new DrupalMedia 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.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ModuleExtensionList $extension_list_module) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->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 [];
}
/**
* {@inheritdoc}
*/
public function getLibraries(Editor $editor) {
return [
'core/jquery',
'core/drupal',
'core/drupal.ajax',
];
}
/**
* {@inheritdoc}
*/
public function getFile() {
return $this->moduleExtensionList->getPath('media') . '/js/plugins/drupalmedia/plugin.js';
}
/**
* {@inheritdoc}
*/
public function getConfig(Editor $editor) {
return [];
}
/**
* {@inheritdoc}
*/
public function isEnabled(Editor $editor) {
if (!$editor->hasAssociatedFilterFormat()) {
return FALSE;
}
// Automatically enable this plugin if the text format associated with this
// text editor uses the media_embed filter.
$filters = $editor->getFilterFormat()->filters();
return $filters->has('media_embed') && $filters->get('media_embed')->status;
}
/**
* {@inheritdoc}
*
* @todo Improve this in https://www.drupal.org/project/drupal/issues/3072063
*/
public function getCssFiles(Editor $editor) {
return [
$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',
];
}
}

View File

@ -187,13 +187,13 @@ class MediaEmbed extends FilterBase implements ContainerFactoryPluginInterface,
// There are a few concerns when rendering an embedded media entity:
// - entity access checking happens not during rendering but during routing,
// and therefore we have to do it explicitly here for the embedded entity;
// and therefore we have to do it explicitly here for the embedded entity.
$build['#access'] = $media->access('view', NULL, TRUE);
// - caching an embedded media entity separately is unnecessary; the host
// entity is already render cached;
// entity is already render cached.
unset($build['#cache']['keys']);
// - Contextual Links do not make sense for embedded entities; we only allow
// the host entity to be contextually managed;
// the host entity to be contextually managed.
$build['#pre_render'][] = static::class . '::disableContextualLinks';
// - default styling may break captioned media embeds; attach asset library
// to ensure captions behave as intended. Do not set this at the root
@ -206,19 +206,29 @@ class MediaEmbed extends FilterBase implements ContainerFactoryPluginInterface,
}
/**
* Builds the render array for a missing media entity.
* Builds the render array for the indicator when media cannot be loaded.
*
* @return array
* A render array.
*
* @todo Make this themeable in https://www.drupal.org/project/drupal/issues/3071713
*/
protected function renderMissingMedia() {
protected function renderMissingMediaIndicator() {
return [
'#theme' => 'image',
'#uri' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')),
'#width' => 180,
'#height' => 180,
'#alt' => $this->t('Missing media.'),
'#title' => $this->t('Missing media.'),
'#type' => 'html_tag',
'#tag' => 'div',
'#value' => $this->t('The referenced media source is missing and needs to be re-embedded.'),
'#attached' => [
'library' => [
'media/media_embed',
],
],
'#attributes' => [
'class' => [
'media-embed-error',
'media-embed-error--missing-source',
],
],
];
}
@ -263,14 +273,27 @@ class MediaEmbed extends FilterBase implements ContainerFactoryPluginInterface,
$build = $media && $view_mode
? $this->renderMedia($media, $view_mode_id, $langcode)
: $this->renderMissingMedia();
: $this->renderMissingMediaIndicator();
if (empty($build['#attributes']['class'])) {
$build['#attributes']['class'] = [];
}
// Any attributes not consumed by the filter should be carried over to the
// rendered embedded entity. For example, `data-align` and `data-caption`
// should be carried over, so that even when embedded media goes missing,
// at least the caption and visual structure won't get lost.
foreach ($node->attributes as $attribute) {
$build['#attributes'][$attribute->nodeName] = $attribute->nodeValue;
if ($attribute->nodeName == 'class') {
// We don't want to overwrite the existing CSS class of the embedded
// media (or if the media entity can't be loaded, the missing media
// indicator). But, we need to merge in CSS classes added by other
// filters, such as filter_align, in order for those filters to work
// properly.
$build['#attributes']['class'] = array_unique(array_merge($build['#attributes']['class'], explode(' ', $attribute->nodeValue)));
}
else {
$build['#attributes'][$attribute->nodeName] = $attribute->nodeValue;
}
}
$this->renderIntoDomNode($build, $node, $result);
@ -303,7 +326,7 @@ class MediaEmbed extends FilterBase implements ContainerFactoryPluginInterface,
* Renders the given render array into the given DOM node.
*
* @param array $build
* The render array to render in isolation
* The render array to render in isolation.
* @param \DOMNode $node
* The DOM node to render into.
* @param \Drupal\filter\FilterProcessResult $result

View File

@ -0,0 +1,8 @@
name: Media CKEditor plugin test
description: 'Provides functionality to test the Media Embed CKEditor integration.'
type: module
package: Testing
version: VERSION
core: 8.x
dependencies:
- drupal:media

View File

@ -0,0 +1,17 @@
<?php
/**
* @file
* Helper module for the Media Embed CKEditor plugin tests.
*/
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
/**
* Implements hook_entity_view_alter().
*/
function media_test_ckeditor_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
// @see \Drupal\Tests\media\FunctionalJavascript\CKEditorIntegrationTest::testPreviewUsesDefaultThemeAndIsClientCacheable()
$build['#attributes']['data-media-embed-test-active-theme'] = \Drupal::theme()->getActiveTheme()->getName();
}

View File

@ -0,0 +1,5 @@
services:
media_test_ckeditor.route_subscriber:
class: Drupal\media_test_ckeditor\Routing\RouteSubscriber
tags:
- { name: event_subscriber }

View File

@ -0,0 +1,25 @@
<?php
namespace Drupal\media_test_ckeditor\Controller;
use Drupal\filter\FilterFormatInterface;
use Drupal\media\Controller\MediaFilterController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Controller to allow testing of error handling in drupalmedia plugin.js.
*/
class TestMediaFilterController extends MediaFilterController {
/**
* {@inheritdoc}
*/
public function preview(Request $request, FilterFormatInterface $filter_format) {
if (\Drupal::state()->get('test_media_filter_controller_throw_error', FALSE)) {
throw new NotFoundHttpException();
}
return parent::preview($request, $filter_format);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Drupal\media_test_ckeditor\Routing;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Listens to the dynamic route events.
*/
class RouteSubscriber extends RouteSubscriberBase {
/**
* {@inheritdoc}
*/
public function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('media.filter.preview')) {
$route->setDefault('_controller', '\Drupal\media_test_ckeditor\Controller\TestMediaFilterController::preview');
}
}
}

View File

@ -0,0 +1,845 @@
<?php
namespace Drupal\Tests\media\FunctionalJavascript;
use Drupal\Component\Utility\Html;
use Drupal\Core\Url;
use Drupal\editor\Entity\Editor;
use Drupal\file\Entity\File;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\media\Entity\Media;
use Drupal\Tests\media\Traits\MediaTypeCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
/**
* @coversDefaultClass \Drupal\media\Plugin\CKEditorPlugin\DrupalMedia
* @group media
*/
class CKEditorIntegrationTest extends WebDriverTestBase {
use MediaTypeCreationTrait;
use TestFileCreationTrait;
/**
* The user to use during testing.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* The sample Media entity to embed.
*
* @var \Drupal\media\MediaInterface
*/
protected $media;
/**
* A host entity with a body field to embed media in.
*
* @var \Drupal\node\NodeInterface
*/
protected $host;
/**
* {@inheritdoc}
*/
protected static $modules = [
'ckeditor',
'media',
'node',
'text',
'media_test_ckeditor',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
FilterFormat::create([
'format' => 'test_format',
'name' => 'Test format',
'filters' => [
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => ['status' => TRUE],
],
])->save();
Editor::create([
'editor' => 'ckeditor',
'format' => 'test_format',
'settings' => [
'toolbar' => [
'rows' => [
[
[
'name' => 'All the things',
'items' => [
'Source',
'Bold',
'Italic',
'DrupalLink',
'DrupalUnlink',
'DrupalImage',
],
],
],
],
],
],
])->save();
// Note that media_install() grants 'view media' to all users by default.
$this->adminUser = $this->drupalCreateUser([
'use text format test_format',
'bypass node access',
]);
// 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' => 'Screaming hairy armadillo',
'field_media_image' => [
[
'target_id' => 1,
'alt' => 'default alt',
'title' => 'default title',
],
],
]);
$this->media->save();
// Create a sample host entity to embed media in.
$this->drupalCreateContentType(['type' => 'blog']);
$this->host = $this->createNode([
'type' => 'blog',
'title' => 'Animals with strange names',
'body' => [
'value' => '<drupal-media data-caption="baz" data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '"></drupal-media>',
'format' => 'test_format',
],
]);
$this->host->save();
$this->drupalLogin($this->adminUser);
}
/**
* Tests that only <drupal-media> tags are processed.
*
* @see \Drupal\Tests\media\Kernel\MediaEmbedFilterTest::testOnlyDrupalMediaTagProcessed()
*/
public function testOnlyDrupalMediaTagProcessed() {
$original_value = $this->host->body->value;
$this->host->body->value = str_replace('drupal-media', 'p', $original_value);
$this->host->save();
// Assert that `<p data-* …>` is not upcast into a CKEditor Widget.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$assert_session = $this->assertSession();
$this->assertEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]', 1000));
$assert_session->elementNotExists('css', 'figure');
$this->host->body->value = $original_value;
$this->host->save();
// Assert that `<drupal-media data-* …>` is upcast into a CKEditor Widget.
$this->getSession()->reload();
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$assert_session->elementExists('css', 'figure');
}
/**
* Tests that failed media embed preview requests inform the end user.
*/
public function testPreviewFailure() {
// Assert that a request to the `media.filter.preview` route that does not
// result in a 200 response (due to server error or network error) is
// handled in the JavaScript by displaying the expected error message.
$this->container->get('state')->set('test_media_filter_controller_throw_error', TRUE);
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$assert_session = $this->assertSession();
$this->assertEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]', 1000));
$assert_session->elementNotExists('css', 'figure');
$error_message = $assert_session->elementExists('css', '.media-embed-error.media-embed-error--preview-error')
->getText();
$this->assertSame('An error occurred while trying to preview the media. Please save your work and reload this page.', $error_message);
// Now assert that the error doesn't appear when the override to force an
// error is removed.
$this->container->get('state')->set('test_media_filter_controller_throw_error', FALSE);
$this->getSession()->reload();
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
}
/**
* The CKEditor Widget must load a preview generated using the default theme.
*/
public function testPreviewUsesDefaultThemeAndIsClientCacheable() {
// Make the node edit form use the admin theme, like on most Drupal sites.
$this->config('node.settings')
->set('use_admin_theme', TRUE)
->save();
$this->container->get('router.builder')->rebuild();
// Allow the test user to view the admin theme.
$this->adminUser->addRole($this->drupalCreateRole(['view the administration theme']));
$this->adminUser->save();
// Configure a different default and admin theme, like on most Drupal sites.
$this->config('system.theme')
->set('default', 'stable')
->set('admin', 'classy')
->save();
// Assert that when looking at an embedded entity in the CKEditor Widget,
// the preview is generated using the default theme, not the admin theme.
// @see media_test_ckeditor_entity_view_alter()
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$assert_session = $this->assertSession();
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$element = $assert_session->elementExists('css', '[data-media-embed-test-active-theme]');
$this->assertSame('stable', $element->getAttribute('data-media-embed-test-active-theme'));
// Assert that the first preview request transferred >500 B over the wire.
// Then toggle source mode on and off. This causes the CKEditor widget to be
// destroyed and then reconstructed. Assert that during this reconstruction,
// a second request is sent. This second request should have transferred 0
// bytes: the browser should have cached the response, thus resulting in a
// much better user experience.
$this->assertGreaterThan(500, $this->getLastPreviewRequestTransferSize());
$this->pressEditorButton('source');
$this->assertNotEmpty($assert_session->waitForElement('css', 'textarea.cke_source'));
$this->pressEditorButton('source');
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'img[src*="image-test.png"]'));
$this->assertSame(0, $this->getLastPreviewRequestTransferSize());
}
/**
* Tests caption editing in the CKEditor widget.
*/
public function testEditableCaption() {
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
// Type in the widget's editable for the caption.
$this->getSession()->switchToIFrame('ckeditor');
$assert_session = $this->assertSession();
$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');
$assert_session->elementExists('css', 'figcaption > em');
$assert_session->elementExists('css', 'figcaption > strong')->click();
// Select the <strong> element and unbold it.
$this->clickPathLinkByTitleAttribute("strong element");
$this->pressEditorButton('bold');
$this->getSession()->switchToIFrame('ckeditor');
$assert_session->elementExists('css', 'figcaption > em');
$assert_session->elementNotExists('css', 'figcaption > strong');
// Select the <em> element and unitalicize it.
$assert_session->elementExists('css', 'figcaption > em')->click();
$this->clickPathLinkByTitleAttribute("em element");
$this->pressEditorButton('italic');
// The "source" button should reveal the HTML source in a state matching
// what is shown in the CKEditor widget.
$this->pressEditorButton('source');
$source = $assert_session->elementExists('css', 'textarea.cke_source');
$value = $source->getValue();
$dom = Html::load($value);
$xpath = new \DOMXPath($dom);
$drupal_media = $xpath->query('//drupal-media')[0];
$this->assertSame('Caught in a landslide! No escape from reality!', $drupal_media->getAttribute('data-caption'));
// Change the caption by modifying the HTML source directly. When exiting
// "source" mode, this should be respected.
$poor_boy_text = "I'm just a <strong>poor boy</strong>, I need no sympathy!";
$drupal_media->setAttribute("data-caption", $poor_boy_text);
$source->setValue(Html::serialize($dom));
$this->pressEditorButton('source');
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$figcaption = $assert_session->waitForElement('css', 'figcaption');
$this->assertNotEmpty($figcaption);
$this->assertSame($poor_boy_text, $figcaption->getHtml());
// Select the <strong> element that we just set in "source" mode. This
// proves that it was indeed rendered by the CKEditor widget.
$strong = $figcaption->find('css', 'strong');
$this->assertNotEmpty($strong);
$strong->click();
$this->pressEditorButton('bold');
// Insert a link into the caption.
$this->clickPathLinkByTitleAttribute("Caption element");
$this->pressEditorButton('drupallink');
$field = $assert_session->waitForElementVisible('xpath', '//input[@name="attributes[href]"]');
$this->assertNotEmpty($field);
$field->setValue('https://www.drupal.org');
$assert_session->elementExists('css', 'button.form-submit')->press();
// Wait for the live preview in the CKEditor widget to finish loading, then
// edit the link; no `data-cke-saved-href` attribute should exist on it.
$this->getSession()->switchToIFrame('ckeditor');
$figcaption = $assert_session->waitForElement('css', 'figcaption');
$page = $this->getSession()->getPage();
// Wait for AJAX refresh.
$page->waitFor(10, function () use ($figcaption) {
return $figcaption->find('xpath', '//a[@href="https://www.drupal.org"]');
});
$assert_session->elementExists('css', 'a', $figcaption)->click();
$this->clickPathLinkByTitleAttribute("a element");
$this->pressEditorButton('drupallink');
$field = $assert_session->waitForElementVisible('xpath', '//input[@name="attributes[href]"]');
$this->assertNotEmpty($field);
$field->setValue('https://www.drupal.org/project/drupal');
$assert_session->elementExists('css', 'button.form-submit')->press();
$this->getSession()->switchToIFrame('ckeditor');
$figcaption = $assert_session->waitForElement('css', 'figcaption');
$page = $this->getSession()->getPage();
// Wait for AJAX refresh.
$page->waitFor(10, function () use ($figcaption) {
return $figcaption->find('xpath', '//a[@href="https://www.drupal.org/project/drupal"]');
});
$this->pressEditorButton('source');
$source = $assert_session->elementExists('css', "textarea.cke_source");
$value = $source->getValue();
$this->assertContains('https://www.drupal.org/project/drupal', $value);
$this->assertNotContains('data-cke-saved-href', $value);
// Save the entity.
$assert_session->buttonExists('Save')->press();
// Verify the saved entity when viewed also contains the captioned media.
$link = $assert_session->elementExists('css', 'figcaption > a');
$this->assertSame('https://www.drupal.org/project/drupal', $link->getAttribute('href'));
$this->assertSame("I'm just a poor boy, I need no sympathy!", $link->getText());
// Edit it again, type a different caption in the widget.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$this->assertNotEmpty($assert_session->waitForElementVisible('css', 'figcaption'));
$this->setCaption('Scaramouch, <em>Scaramouch</em>, will you do the <strong>Fandango</strong>?');
// Erase the caption in the CKEditor Widget, verify the <figcaption> still
// exists and contains placeholder text, then type something else.
$this->setCaption('');
$this->getSession()->switchToIFrame('ckeditor');
$assert_session->elementContains('css', 'figcaption', '');
$assert_session->elementAttributeContains('css', 'figcaption', 'data-placeholder', 'Enter caption here');
$this->setCaption('Fin.');
$this->getSession()->switchToIFrame('ckeditor');
$assert_session->elementContains('css', 'figcaption', 'Fin.');
}
/**
* Tests linkability of the CKEditor widget.
*
* @dataProvider linkabilityProvider
*/
public function testLinkability($drupalimage_is_enabled) {
if (!$drupalimage_is_enabled) {
// Remove the `drupalimage` plugin's `DrupalImage` button.
$editor = Editor::load('test_format');
$settings = $editor->getSettings();
$rows = $settings['toolbar']['rows'];
foreach ($rows as $row_key => $row) {
foreach ($row as $group_key => $group) {
foreach ($group['items'] as $item_key => $item) {
if ($item === 'DrupalImage') {
unset($settings['toolbar']['rows'][$row_key][$group_key]['items'][$item_key]);
}
}
}
}
$editor->setSettings($settings);
$editor->save();
}
$this->host->body->value .= '<p>The pirate is irate.</p><p>';
if ($drupalimage_is_enabled) {
// Add an image with a link wrapped around it.
$uri = $this->media->field_media_image->entity->getFileUri();
$src = file_url_transform_relative(file_create_url($uri));
$this->host->body->value .= '<a href="http://www.drupal.org/association"><img alt="drupalimage test image" data-entity-type="" data-entity-uuid="" src="' . $src . '" /></a></p>';
}
$this->host->save();
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$assert_session = $this->assertSession();
// Select the CKEditor Widget.
$drupalmedia = $assert_session->waitForElementVisible('css', 'drupal-media');
$this->assertNotEmpty($drupalmedia);
$drupalmedia->click();
// While the CKEditor Widget is selected, assert the context menu does not
// contain link-related context menu items.
$this->openContextMenu();
$this->assignNameToCkeditorPanelIframe();
$this->getSession()->switchToIFrame('panel');
$this->assertContextMenuItemNotExists('Edit Link');
$this->assertContextMenuItemNotExists('Unlink');
$this->closeContextMenu();
// While the CKEditor Widget is selected, click the "link" button.
$this->pressEditorButton('drupallink');
$assert_session->waitForId('drupal-modal');
// Enter a link in the link dialog and save.
$field = $assert_session->waitForElementVisible('xpath', '//input[@name="attributes[href]"]');
$this->assertNotEmpty($field);
$field->setValue('https://www.drupal.org');
$assert_session->elementExists('css', 'button.form-submit')->press();
$this->getSession()->switchToIFrame('ckeditor');
$link = $assert_session->waitForElementVisible('css', 'a[href="https://www.drupal.org"]');
$this->assertNotEmpty($link);
// Select the CKEditor Widget again and assert the context menu now does
// contain link-related context menu items.
$drupalmedia = $assert_session->waitForElementVisible('css', 'drupal-media');
$this->assertNotEmpty($drupalmedia);
$drupalmedia->click();
$this->openContextMenu();
$this->getSession()->switchToIFrame('panel');
$this->assertContextMenuItemExists('Edit Link');
$this->assertContextMenuItemExists('Unlink');
$this->closeContextMenu();
// Save the entity.
$this->getSession()->switchToIFrame();
$assert_session->buttonExists('Save')->press();
// Verify the saved entity when viewed also contains the linked media.
$assert_session->elementExists('css', 'figure > a[href="https://www.drupal.org"] > .media--type-image > .field--type-image > img[src*="image-test.png"]');
// Test that `drupallink` also still works independently: inserting a link
// is possible.
$this->drupalGet($this->host->toUrl('edit-form'));
$this->waitForEditor();
$this->pressEditorButton('drupallink');
$assert_session->waitForId('drupal-modal');
$field = $assert_session->waitForElementVisible('xpath', '//input[@name="attributes[href]"]');
$this->assertNotEmpty($field);
$field->setValue('https://wikipedia.org');
$assert_session->elementExists('css', 'button.form-submit')->press();
$this->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$link = $assert_session->waitForElementVisible('css', 'body > a[href="https://wikipedia.org"]');
$this->assertNotEmpty($link);
$assert_session->elementExists('css', 'body > .cke_widget_drupalmedia > drupal-media > figure > a[href="https://www.drupal.org"]');
// Select the CKEditor Widget again and assert the `drupalunlink` button is
// enabled. Also assert the context menu again contains link-related context
// menu items.
$drupalmedia = $assert_session->waitForElementVisible('css', 'drupal-media');
$this->assertNotEmpty($drupalmedia);
$drupalmedia->click();
$this->openContextMenu();
$this->getSession()->switchToIFrame();
$this->assertEditorButtonEnabled('drupalunlink');
$this->assignNameToCkeditorPanelIframe();
$this->getSession()->switchToIFrame('panel');
$this->assertContextMenuItemExists('Edit Link');
$this->assertContextMenuItemExists('Unlink');
// Test that moving focus to another element causes the `drupalunlink`
// button to become disabled and causes link-related context menu items to
// disappear.
$this->getSession()->switchToIFrame();
$this->getSession()->switchToIFrame('ckeditor');
$p = $assert_session->waitForElementVisible('xpath', "//p[contains(text(), 'The pirate is irate')]");
$this->assertNotEmpty($p);
$p->click();
$this->assertEditorButtonDisabled('drupalunlink');
$this->getSession()->switchToIFrame('panel');
$this->assertContextMenuItemExists('Edit Link');
$this->assertContextMenuItemExists('Unlink');
// To switch from the context menu iframe ("panel") back to the CKEditor
// iframe, we first have to reset to top frame.
$this->getSession()->switchToIFrame();
$this->getSession()->switchToIFrame('ckeditor');
// Test that moving focus to the `drupalimage` CKEditor Widget enables the
// `drupalunlink` button again, because it is a linked image.
if ($drupalimage_is_enabled) {
$drupalimage = $assert_session->waitForElementVisible('xpath', '//img[@alt="drupalimage test image"]');
$this->assertNotEmpty($drupalimage);
$drupalimage->click();
$this->assertEditorButtonEnabled('drupalunlink');
$this->getSession()->switchToIFrame('panel');
$this->assertContextMenuItemExists('Edit Link');
$this->assertContextMenuItemExists('Unlink');
$this->getSession()->switchToIFrame();
$this->getSession()->switchToIFrame('ckeditor');
}
// Tests the `drupalunlink` button for the `drupalmedia` CKEditor Widget.
$drupalmedia->click();
$this->assertEditorButtonEnabled('drupalunlink');
$this->getSession()->switchToIFrame('panel');
$this->assertContextMenuItemExists('Edit Link');
$this->assertContextMenuItemExists('Unlink');
$this->pressEditorButton('drupalunlink');
$this->assertEditorButtonDisabled('drupalunlink');
$this->getSession()->switchToIFrame('ckeditor');
$assert_session->elementNotExists('css', 'figure > a[href="https://www.drupal.org"] > .media--type-image > .field--type-image > img[src*="image-test.png"]');
$assert_session->elementExists('css', 'figure .media--type-image > .field--type-image > img[src*="image-test.png"]');
if ($drupalimage_is_enabled) {
// Tests the `drupalunlink` button for the `drupalimage` CKEditor Widget.
$drupalimage->click();
$this->assertEditorButtonEnabled('drupalunlink');
$this->pressEditorButton('drupalunlink');
$this->assertEditorButtonDisabled('drupalunlink');
$this->getSession()->switchToIFrame('ckeditor');
$assert_session->elementNotExists('css', 'p > a[href="https://www.drupal.org/association"] > img[src*="image-test.png"]');
$assert_session->elementExists('css', 'p > img[src*="image-test.png"]');
}
}
/**
* Data Provider for ::testLinkability.
*/
public function linkabilityProvider() {
return [
'linkability when `drupalimage` is enabled' => [
TRUE,
],
'linkability when `drupalimage` is disabled' => [
FALSE,
],
];
}
/**
* Tests preview route access.
*
* @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 previewAccessProvider
*/
public function testEmbedPreviewAccess($media_embed_enabled, $can_use_format) {
$format = FilterFormat::create([
'format' => $this->randomMachineName(),
'name' => $this->randomString(),
'filters' => [
'filter_align' => ['status' => TRUE],
'filter_caption' => ['status' => TRUE],
'media_embed' => ['status' => $media_embed_enabled],
],
]);
$format->save();
$permissions = [
'bypass node access',
];
if ($can_use_format) {
$permissions[] = $format->getPermissionName();
}
$this->drupalLogin($this->drupalCreateUser($permissions));
$text = '<drupal-media data-caption="baz" data-entity-type="media" data-entity-uuid="' . $this->media->uuid() . '"></drupal-media>';
$route_parameters = ['filter_format' => $format->id()];
$options = ['query' => ['text' => $text]];
$this->drupalGet(Url::fromRoute('media.filter.preview', $route_parameters, $options));
$assert_session = $this->assertSession();
if ($media_embed_enabled && $can_use_format) {
$assert_session->elementExists('css', 'img');
$assert_session->responseContains('baz');
}
else {
$assert_session->responseContains('You are not authorized to access this page.');
}
}
/**
* Data provider for ::testEmbedPreviewAccess.
*/
public function previewAccessProvider() {
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,
],
];
}
/**
* Tests that alignment is reflected onto the CKEditor Widget wrapper.
*/
public function testAlignmentClasses() {
$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->assignNameToCkeditorIframe();
$this->getSession()->switchToIFrame('ckeditor');
$wrapper = $assert_session->waitForElementVisible('css', '.cke_widget_drupalmedia', 2000);
$this->assertNotEmpty($wrapper);
$this->assertTrue($wrapper->hasClass('align-' . $alignment));
}
}
/**
* Gets the transfer size of the last preview request.
*
* @return int
*/
protected function getLastPreviewRequestTransferSize() {
$this->getSession()->switchToIFrame();
$javascript = <<<JS
(function(){
return window.performance
.getEntries()
.filter(function (entry) {
return entry.initiatorType == 'xmlhttprequest' && entry.name.indexOf('/media/test_format/preview') !== -1;
})
.pop()
.transferSize;
})()
JS;
return $this->getSession()->evaluateScript($javascript);
}
/**
* Set the text of the editable caption to the given text.
*
* @param string $text
* The text to set in the caption.
*/
protected function setCaption($text) {
$this->getSession()->switchToIFrame();
$select_and_edit_caption = "var editor = CKEDITOR.instances['edit-body-0-value'];
var figcaption = editor.widgets.getByElement(editor.editable().findOne('figcaption'));
figcaption.editables.caption.setData('" . $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.
*
* Note that this iframe doesn't appear until context menu appears.
*
* @see \Behat\Mink\Session::switchToIFrame()
*/
protected function assignNameToCkeditorPanelIframe() {
$javascript = <<<JS
(function(){
document.getElementsByClassName('cke_panel_frame')[0].id = 'panel';
})()
JS;
$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.
*
* @param string $instance_id
* The CKEditor instance ID.
*/
protected function openContextMenu($instance_id = 'edit-body-0-value') {
$this->getSession()->switchToIFrame();
$script = <<<JS
(function() {
var editor = CKEDITOR.instances["$instance_id"];
editor.contextMenu.open(editor.widgets.selected[0].element);
}());
JS;
$this->getSession()->executeScript($script);
}
/**
* Asserts that a context menu item exists by aria-label attribute.
*
* @param string $label
* The `aria-label` attribute value of the context menu item.
*/
protected function assertContextMenuItemExists($label) {
$this->assertSession()->elementExists('xpath', '//a[@aria-label="' . $label . '"]');
}
/**
* Asserts that a context menu item does not exist by aria-label attribute.
*
* @param string $label
* The `aria-label` attribute value of the context menu item.
*/
protected function assertContextMenuItemNotExists($label) {
$this->assertSession()->elementNotExists('xpath', '//a[@aria-label="' . $label . '"]');
}
/**
* Closes the open context menu.
*
* @param string $instance_id
* The CKEditor instance ID.
*/
protected function closeContextMenu($instance_id = 'edit-body-0-value') {
$this->getSession()->switchToIFrame();
$script = <<<JS
(function() {
var editor = CKEDITOR.instances["$instance_id"];
editor.contextMenu.hide();
}());
JS;
$this->getSession()->executeScript($script);
}
/**
* Clicks a link in the editor's path links with the given title text.
*
* @param string $text
* The title attribute of the link to click.
*
* @throws \Behat\Mink\Exception\ElementNotFoundException
*/
protected function clickPathLinkByTitleAttribute($text) {
$this->getSession()->switchToIFrame();
$selector = '//span[@id="cke_1_path"]//a[@title="' . $text . '"]';
$this->assertSession()->elementExists('xpath', $selector)->click();
}
}

View File

@ -87,7 +87,7 @@ class MediaAccessControlHandlerTest extends MediaKernelTestBase {
* @param string[] $expected_cache_contexts
* Expected contexts.
* @param string[] $expected_cache_tags
* Expected cache tags
* Expected cache tags.
* @param \Drupal\Core\Access\AccessResultInterface $actual
* The actual access result.
*/

View File

@ -258,30 +258,32 @@ class MediaEmbedFilterTest extends MediaEmbedFilterTestBase {
*
* @dataProvider providerMissingEntityIndicator
*/
public function testMissingEntityIndicator($uuid) {
public function testMissingEntityIndicator($uuid, array $filter_ids, array $additional_attributes) {
$content = $this->createEmbedCode([
'data-entity-type' => 'media',
'data-entity-uuid' => $uuid,
'data-view-mode' => 'foobar',
]);
] + $additional_attributes);
// If the UUID being used in the embed is that of the sample entity, first
// assert that it currently results in a functional embed, then delete it.
if ($uuid === static::EMBEDDED_ENTITY_UUID) {
$this->applyFilter($content);
$result = $this->processText($content, 'en', $filter_ids);
$this->setRawContent($result->getProcessedText());
$this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="foobar"]'));
$this->embeddedEntity->delete();
}
$this->applyFilter($content);
$result = $this->processText($content, 'en', $filter_ids);
$this->setRawContent($result->getProcessedText());
$this->assertCount(0, $this->cssSelect('div[data-media-embed-test-view-mode="foobar"]'));
$deleted_embed_warning = $this->cssSelect('img')[0];
$this->assertNotEmpty($deleted_embed_warning);
$this->assertHasAttributes($deleted_embed_warning, [
'alt' => 'Missing media.',
'src' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')),
'title' => 'Missing media.',
]);
$this->assertCount(1, $this->cssSelect('div.media-embed-error'));
$error_element = $this->cssSelect('div.media-embed-error')[0];
$expected_class = 'media-embed-error media-embed-error--missing-source';
if (in_array('filter_align', $filter_ids, TRUE) && !empty($additional_attributes['data-align'])) {
$expected_class .= ' align-' . $additional_attributes['data-align'];
}
$this->assertSame($expected_class, (string) $error_element['class']);
}
/**
@ -289,17 +291,57 @@ class MediaEmbedFilterTest extends MediaEmbedFilterTestBase {
*/
public function providerMissingEntityIndicator() {
return [
'valid UUID but for a deleted entity' => [
static::EMBEDDED_ENTITY_UUID,
'invalid UUID' => [
'uuid' => 'invalidUUID',
'filter_ids' => [
'filter_align',
'filter_caption',
'media_embed',
],
'additional_attributes' => [],
],
'node; invalid UUID' => [
'invalidUUID',
'valid UUID but for a deleted entity' => [
'uuid' => static::EMBEDDED_ENTITY_UUID,
'filter_ids' => [
'filter_align',
'filter_caption',
'media_embed',
],
'additional_attributes' => [],
],
'invalid UUID; data-align attribute without filter_align enabled' => [
'uuid' => 'invalidUUID',
'filter_ids' => [
'filter_caption',
'media_embed',
],
'additional_attributes' => ['data-align' => 'right'],
],
'invalid UUID; data-align attribute with filter_align enabled' => [
'uuid' => 'invalidUUID',
'filter_ids' => [
'filter_align',
'filter_caption',
'media_embed',
],
'additional_attributes' => ['data-align' => 'left'],
],
'valid UUID but for a deleted entity; data-align attribute with filter_align enabled' => [
'uuid' => static::EMBEDDED_ENTITY_UUID,
'filter_ids' => [
'filter_align',
'filter_caption',
'media_embed',
],
'additional_attributes' => ['data-align' => 'center'],
],
];
}
/**
* Tests that only <drupal-media> tags are processed.
*
* @see \Drupal\Tests\media\FunctionalJavascript\CKEditorIntegrationTest::testOnlyDrupalMediaTagProcessed()
*/
public function testOnlyDrupalMediaTagProcessed() {
$content = $this->createEmbedCode([
@ -339,7 +381,7 @@ class MediaEmbedFilterTest extends MediaEmbedFilterTestBase {
* @covers \Drupal\filter\Plugin\Filter\FilterCaption
* @dataProvider providerFilterIntegration
*/
public function testFilterIntegration(array $filter_ids, array $additional_attributes, $verification_selector, $expected_verification_success, array $expected_asset_libraries, $prefix = '', $suffix = '') {
public function testFilterIntegration(array $filter_ids, array $additional_attributes, $verification_selector, $expected_verification_success, array $expected_asset_libraries = [], $prefix = '', $suffix = '') {
$content = $this->createEmbedCode([
'data-entity-type' => 'media',
'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID,

View File

@ -174,7 +174,7 @@ abstract class MediaEmbedFilterTestBase extends KernelTestBase {
* The filtered text, wrapped in a FilterProcessResult object, and possibly
* with associated assets, cacheability metadata and placeholders.
*
* @see \Drupal\Tests\entity_embed\Kernel\EntityEmbedFilterTestBase::createEmbedCode()
* @see \Drupal\Tests\media\Kernel\MediaEmbedFilterTestBase::createEmbedCode()
* @see \Drupal\KernelTests\AssertContentTrait::setRawContent()
*/
protected function applyFilter($text, $langcode = 'en') {

View File

@ -8,3 +8,9 @@
float: none;
margin: unset;
}
/* Undo margin added to figure in core/assets/vendor/normalize-css/normalize.css */
figure.caption-drupal-media.align-center {
margin-right: auto;
margin-left: auto;
}

View File

@ -0,0 +1,20 @@
/**
* @file
* Media Embed filter: default styling for media embed errors.
*/
/**
* The caption filter's styling overrides ours, so add a more specific selector
* to account for that.
*/
.media-embed-error,
.caption > .media-embed-error {
max-width: 200px;
padding: 100px 20px 20px;
text-align: center;
background-color: #ebebeb;
background-image: url(../../../../modules/media/images/icons/no-thumbnail.png);
background-repeat: no-repeat;
background-position: center top;
background-size: 100px 100px;
}

View File

@ -158,6 +158,11 @@ libraries-override:
component:
css/oembed.frame.css: css/media/oembed.frame.css
media/media_embed:
css:
component:
css/filter.media_embed.css: css/media/filter.media_embed.css
menu_ui/drupal.menu_ui.adminforms:
css:
theme: