/** * @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 {}; }); } } 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; // 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': { attributes: { '!data-entity-type': true, '!data-entity-uuid': true, 'data-align': true, 'data-caption': true, alt: true, title: true, }, classes: {}, }, }, // Minimum HTML which is required by this widget to work. // This does not use the object format used above, but a // CKEDITOR.style instance, because requiredContent does not support // the object format. // @see https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR_filter_contentRule.html requiredContent: new CKEDITOR.style({ element: 'drupal-media', attributes: { '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'), }, }, getLabel() { if (this.data.label) { return this.data.label; } return Drupal.t('Embedded media'); }, upcast(element, data) { const { attributes } = element; // 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'); // Add space to the empty caption to allow the server-side text // filter to render a caption, allowing the placeholder-rendering // CSS to work. if (data.hasCaption && data.attributes['data-caption'] === '') { data.attributes['data-caption'] = ' '; } data.label = null; 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]; } }); } // @see media_field_widget_form_alter() const hostEntityLangcode = document .getElementById(editor.name) .getAttribute('data-media-embed-host-entity-langcode'); if (hostEntityLangcode) { data.hostEntityLangcode = hostEntityLangcode; } return element; }, destroy() { this._tearDownDynamicEditables(); }, data(event) { // Only run during changes. if (this.oldData) { // The server-side text filter plugin treats both an empty // `data-caption` attribute and a non-existing one the same: it // does not render a caption. But in the CKEditor Widget, we need // to be able to show an empty caption with placeholder text using // CSS even when technically there is no `data-caption` attribute // value yet. That's why this CKEditor Widget has an independent // `hasCaption` boolean (which is not an attribute) to know when // to generate a non-empty `data-caption` attribute when the // content creator has enabled caption: this makes the server-side // text filter render a caption, allowing the placeholder-rendering // CSS to work. // @see core/modules/filter/css/filter.caption.css // @see ckeditor_ckeditor_css_alter() if (!this.data.hasCaption && this.oldData.hasCaption) { delete this.data.attributes['data-caption']; } else if ( this.data.hasCaption && !this.data.attributes['data-caption'] ) { this.data.attributes['data-caption'] = ' '; } } if (this._previewNeedsServerSideUpdate()) { editor.fire('lockSnapshot'); this._tearDownDynamicEditables(); this._loadPreview(widget => { widget._setUpDynamicEditables(); widget._setUpEditButton(); editor.fire('unlockSnapshot'); }); } // Remove old attributes from drupal-media element within the widget. if (this.oldData) { Object.keys(this.oldData.attributes).forEach(attrName => { this.element.removeAttribute(attrName); }); } // Add attributes to drupal-media element within the widget. this.element.setAttributes(this.data.attributes); // 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, }); // Some browsers will add a
tag to a newly created DOM element // with no content. Remove this
if it is the only thing in the // caption. Our placeholder support requires the element to be // entirely empty. // @see core/modules/filter/css/filter.caption.css // @see core/modules/ckeditor/js/plugins/drupalimagecaption/plugin.es6.js if ( captionEditable.$.childNodes.length === 1 && captionEditable.$.childNodes.item(0).nodeName === 'BR' ) { captionEditable.$.removeChild( captionEditable.$.childNodes.item(0), ); } } }, /** * Injects HTML for edit button into the preview that was just loaded. */ _setUpEditButton() { // No buttons for missing media. if (this.element.findOne('.media-embed-error')) { return; } /** * Determines if a node is an element node. * * @param {CKEDITOR.dom.node} n * A DOM node to evaluate. * * @return {bool} * Returns true if node is an element node and not a non-element * node (such as NODE_TEXT, NODE_COMMENT, NODE_DOCUMENT or * NODE_DOCUMENT_FRAGMENT). * * @see https://ckeditor.com/docs/ckeditor4/latest/api/CKEDITOR.html#property-NODE_ELEMENT */ const isElementNode = function(n) { return n.type === CKEDITOR.NODE_ELEMENT; }; // Find the actual embedded media in the DOM. const embeddedMediaContainer = this.data.hasCaption ? this.element.findOne('figure') : this.element; let embeddedMedia = embeddedMediaContainer.getFirst(isElementNode); // If there is a link, the top-level element is the `a` tag, and the // embedded media will be within the `a` tag. if (this.data.link) { embeddedMedia = embeddedMedia.getFirst(isElementNode); } // To allow the edit button to be absolutely positioned, the parent // element must be positioned relative. embeddedMedia.setStyle('position', 'relative'); const editButton = CKEDITOR.dom.element.createFromHtml( Drupal.theme('mediaEmbedEditButton'), ); embeddedMedia.getFirst().insertBeforeMe(editButton); // Make the edit button do things. const widget = this; this.element .findOne('.media-library-item__edit') .on('click', event => { const saveCallback = function(values) { event.cancel(); editor.fire('saveSnapshot'); if (values.hasOwnProperty('attributes')) { // Combine the dialog attributes with the widget attributes. // This copies the properties from widget.data.attributes to // values.attributes. (Properties already present // in values.attributes are not overwritten.) CKEDITOR.tools.extend( values.attributes, widget.data.attributes, ); // Allow the dialog to delete attributes by setting them // to `false` or `none`. For example: `alt`. Object.keys(values.attributes).forEach(prop => { if ( values.attributes[prop] === false || (prop === 'data-align' && values.attributes[prop] === 'none') ) { delete values.attributes[prop]; } }); } widget.setData({ attributes: values.attributes, hasCaption: !!values.hasCaption, }); editor.fire('saveSnapshot'); }; Drupal.ckeditor.openDialog( editor, Drupal.url( `editor/dialog/media/${editor.config.drupal.format}`, ), widget.data, saveCallback, {}, ); }); // Allow opening the dialog with the return key or the space bar // by triggering a click event when a keydown event occurs on // the edit button. this.element .findOne('.media-library-item__edit') .on('keydown', event => { // The character code for the return key. const returnKey = 13; // The character code for the space bar. const spaceBar = 32; if (typeof event.data !== 'undefined') { const keypress = event.data.getKey(); if (keypress === returnKey || keypress === spaceBar) { // Clicks the edit button that triggered the 'keydown' // event. event.sender.$.click(); } // Stop propagation to keep the return key from // adding a line break. event.data.$.stopPropagation(); event.data.$.stopImmediatePropagation(); } }); }, _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. * * @return {boolean} * Returns true if the data hashes differ. */ _previewNeedsServerSideUpdate() { // When the widget is first loading, it of course needs to still get a preview! 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']; // The media entity's label is server-side data and cannot be // modified by the content author. delete dataToHash.label; // 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(), uuid: this.data.attributes['data-entity-uuid'], }, dataType: 'html', success: (previewHtml, textStatus, jqXhr) => { this.element.setHtml(previewHtml); this.setData( 'label', jqXhr.getResponseHeader('Drupal-Media-Label'), ); callback(this); }, error: () => { this.element.setHtml(Drupal.theme('mediaEmbedPreviewError')); }, }); }, }); }, afterInit(editor) { linkCommandIntegrator(editor); }, }); })(jQuery, Drupal, CKEDITOR);