diff --git a/.gitignore b/.gitignore index 7579f74..15188ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ vendor composer.lock +node_modules +package.lock diff --git a/entity_embed.libraries.yml b/entity_embed.libraries.yml index c92858a..75a5fc7 100644 --- a/entity_embed.libraries.yml +++ b/entity_embed.libraries.yml @@ -17,3 +17,20 @@ caption: css/entity_embed.filter.caption.css: {} dependencies: - filter/caption + +# CKEditor 5 +entity_embed: + js: + js/build/drupalentity.js: { minified: true } + dependencies: + - core/ckeditor5 + - core/drupal + +admin.entity_embed: + js: + js/entity_embed.set_dynamic_icons.js: {} + dependencies: + - core/drupal + - core/jquery + - core/once + - core/drupalSettings diff --git a/entity_embed.module b/entity_embed.module index 89e28c0..ddbb160 100644 --- a/entity_embed.module +++ b/entity_embed.module @@ -5,11 +5,18 @@ * Framework for allowing entities to be embedded in CKEditor. */ +use Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition; +use Drupal\Component\Utility\Html; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Entity\EntityInterface; use Drupal\Component\Serialization\Json; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\embed\EmbedButtonInterface; +use Drupal\embed\Entity\EmbedButton; +use Drupal\entity_embed\Event\EmbedButtonEvent; /** * Implements hook_help(). @@ -298,3 +305,57 @@ function entity_embed_field_widget_single_element_form_alter(&$element, FormStat $element['#attributes']['data-entity_embed-host-entity-langcode'] = $items->getEntity()->language()->getId(); } } + +/** + * Implements hook_ENTITY_TYPE_presave(). + */ +function entity_embed_embed_button_presave(EntityInterface $entity) { + // Invalidate the CKEditor 5 plugin cache, so new toolbar items will appear + // based on which Embed Buttons are configured. + /** @var \Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface $ckeditor5_plugin_manager */ + if (\Drupal::hasService('plugin.manager.ckeditor5.plugin')) { + $ckeditor5_plugin_manager = \Drupal::service('plugin.manager.ckeditor5.plugin'); + $ckeditor5_plugin_manager->clearCachedDefinitions(); + // @see entity_embed_library_info_alter() + Cache::invalidateTags(['library_info']); + } +} + +/** + * Implements hook_library_info_alter(). + */ +function entity_embed_library_info_alter(&$libraries, $extension) { + // Create the drupalSettings dynamically based on embed buttons for the + // admin library. + if ($extension === 'entity_embed' && isset($libraries['admin.entity_embed'])) { + $embed_buttons = \Drupal::entityTypeManager() + ->getStorage('embed_button') + ->loadMultiple(); + foreach ($embed_buttons as $embed_button) { + $libraries['admin.entity_embed']['drupalSettings']['embedButtons'][$embed_button->id()] = [ + 'id' => $embed_button->id(), + 'icon' => $embed_button->getIconUrl(), + ]; + + } + $libraries['admin.entity_embed']['drupalSettings']['modulePath'] = \Drupal::service('extension.list.module')->getPath('entity_embed'); + } +} + +/** + * Implements hook_ckeditor4to5upgrade_plugin_info_alter(). + */ +function entity_embed_ckeditor4to5upgrade_plugin_info_alter(array &$plugin_definitions): void { + // Get the user set buttons and add them to the entity embed plugin definition + $buttons = []; + $embed_buttons = \Drupal::entityTypeManager() + ->getStorage('embed_button') + ->loadMultiple(); + foreach ($embed_buttons as $embed_button) { + $buttons[] = $embed_button->id(); + } + + if ($buttons) { + $plugin_definitions['entity_embed']["cke4_buttons"] = $buttons; + } +} diff --git a/js/build/drupalentity.js b/js/build/drupalentity.js new file mode 100644 index 0000000..20d2695 --- /dev/null +++ b/js/build/drupalentity.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.CKEditor5=e():(t.CKEditor5=t.CKEditor5||{},t.CKEditor5.drupalentity=e())}(self,(()=>(()=>{var t={"ckeditor5/src/core.js":(t,e,i)=>{t.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/ui.js":(t,e,i)=>{t.exports=i("dll-reference CKEditor5.dll")("./src/ui.js")},"ckeditor5/src/widget.js":(t,e,i)=>{t.exports=i("dll-reference CKEditor5.dll")("./src/widget.js")},"dll-reference CKEditor5.dll":t=>{"use strict";t.exports=CKEditor5.dll}},e={};function i(r){var n=e[r];if(void 0!==n)return n.exports;var o=e[r]={exports:{}};return t[r](o,o.exports,i),o.exports}i.d=(t,e)=>{for(var r in e)i.o(e,r)&&!i.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:e[r]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var r={};return(()=>{"use strict";i.d(r,{default:()=>u});var t=i("ckeditor5/src/core.js"),e=i("ckeditor5/src/widget.js");class n extends t.Command{execute(t){const{model:e}=this.editor,i=this.editor.plugins.get("EntityEmbedEditing"),r=Object.entries(i.attrs).reduce(((t,[e,i])=>(t[i]=e,t)),{}),n=Object.keys(t).reduce(((e,i)=>(r[i]&&(e[r[i]]=t[i]),e)),{});e.change((t=>{e.insertContent(function(t,e){return t.createElement("drupalEntity",e)}(t,n))}))}refresh(){const t=this.editor.model,e=t.document.selection,i=t.schema.findAllowedParent(e.getFirstPosition(),"drupalEntity");this.isEnabled=null!==i}}class o extends t.Plugin{static get requires(){return[e.Widget]}init(){this.attrs={alt:"alt",title:"title",dataCaption:"data-caption",dataAlign:"data-align",drupalEntityLangCode:"data-langcode",drupalEntityEntityType:"data-entity-type",drupalEntityEntityUuid:"data-entity-uuid",drupalEntityEmbedButton:"data-embed-button",drupalEntityEmbedDisplay:"data-entity-embed-display",drupalEntityEmbedDisplaySettings:"data-entity-embed-display-settings"};const t=this.editor.config.get("entityEmbed");t&&(this.options=t,this.labelError=Drupal.t("Preview failed"),this.previewError=`\n

${Drupal.t("An error occurred while trying to preview the embedded content. Please save your work and reload this page.")}

\n `,this._defineSchema(),this._defineConverters(),this.editor.commands.add("insertEntityEmbed",new n(this.editor)))}_defineSchema(){this.editor.model.schema.register("drupalEntity",{isObject:!0,isContent:!0,isBlock:!0,allowWhere:"$block",allowAttributes:Object.keys(this.attrs)}),this.editor.editing.view.domConverter.blockElements.push("drupal-entity")}_defineConverters(){const{conversion:t}=this.editor;t.for("upcast").elementToElement({model:"drupalEntity",view:{name:"drupal-entity"}}),t.for("dataDowncast").elementToElement({model:"drupalEntity",view:{name:"drupal-entity"}}),t.for("editingDowncast").elementToElement({model:"drupalEntity",view:(t,{writer:i})=>{const r=i.createContainerElement("figure",{class:"drupal-entity"});return i.setCustomProperty("drupalEntity",!0,r),(0,e.toWidget)(r,i,{label:Drupal.t("Entity Embed widget")})}}).add((t=>(t.on("attribute:drupalEntityEntityUuid:drupalEntity",((t,e,i)=>{const r=i.writer,n=e.item,o=i.mapper.toViewElement(e.item);let a=this._getPreviewContainer(o.getChildren());if(a){if("ready"!==a.getAttribute("data-drupal-entity-preview"))return;r.setAttribute("data-drupal-entity-preview","loading",a)}else a=r.createRawElement("div",{"data-drupal-entity-preview":"loading"}),r.insert(r.createPositionAt(o,0),a);this._loadPreview(n).then((({label:t,preview:e})=>{a&&this.editor.editing.view.change((i=>{const r=i.createRawElement("div",{"data-drupal-entity-preview":"ready","aria-label":t},(t=>{t.innerHTML=e}));i.insert(i.createPositionBefore(a),r),i.remove(a)}))}))})),t))),Object.keys(this.attrs).forEach((e=>{const i={model:{key:e,name:"drupalEntity"},view:{name:"drupal-entity",key:this.attrs[e]}};t.for("dataDowncast").attributeToAttribute(i),t.for("upcast").attributeToAttribute(i)}))}async _loadPreview(t){const e={text:this._renderElement(t)},i=await fetch(Drupal.url("embed/preview/"+this.options.format+"?"+new URLSearchParams(e)),{headers:{"X-Drupal-EmbedPreview-CSRF-Token":this.options.previewCsrfToken}});if(i.ok){return{label:Drupal.t("Entity Embed widget"),preview:await i.text()}}return{label:this.labelError,preview:this.previewError}}_renderElement(t){const e=this.editor.model.change((e=>{const i=e.createDocumentFragment(),r=e.cloneElement(t,!1);return e.append(r,i),i}));return this.editor.data.stringify(e)}_getPreviewContainer(t){for(const e of t){if(e.hasAttribute("data-drupal-entity-preview"))return e;if(e.childCount){const t=this._getPreviewContainer(e.getChildren());if(t)return t}}return null}static get pluginName(){return"EntityEmbedEditing"}}var a=i("ckeditor5/src/ui.js");class d extends t.Plugin{static get requires(){return[e.WidgetToolbarRepository]}init(){const e=this.editor,i=e.plugins.get("EntityEmbedEditing"),r=e.config.get("entityEmbed"),{dialogSettings:n={}}=r;e.ui.componentFactory.add("entityEmbedEdit",(o=>{let d=new a.ButtonView(o);return d.set({label:e.t("Edit"),icon:t.icons.pencil,tooltip:!0}),this.listenTo(d,"execute",(t=>{let o=e.model.document.selection.getSelectedElement(),a=Drupal.url("entity-embed/dialog/"+r.format+"/"+o.getAttribute("drupalEntityEmbedButton")),d={};for(let[t,e]of o.getAttributes()){let r=i.attrs[t];r&&(d[r]=e)}this._openDialog(a,d,(({attributes:t})=>{e.execute("insertEntityEmbed",t),e.editing.view.focus()}),n)})),d}))}afterInit(){const{editor:t}=this;if(!t.plugins.has("WidgetToolbarRepository"))return;t.plugins.get("WidgetToolbarRepository").register("entityEmbed",{items:["entityEmbedEdit"],getRelatedElement(t){const i=t.getSelectedElement();return i&&(0,e.isWidget)(i)&&i.getCustomProperty("drupalEntity")?i:null}})}_openDialog(t,e,i,r){const n=r.dialogClass?r.dialogClass.split(" "):[];n.push("ui-dialog--narrow"),r.dialogClass=n.join(" "),r.autoResize=window.matchMedia("(min-width: 600px)").matches,r.width="auto";Drupal.ajax({dialog:r,dialogType:"modal",selector:".ckeditor5-dialog-loading-link",url:t,progress:{type:"fullscreen"},submit:{editor_object:e}}).execute(),Drupal.ckeditor5.saveCallback=i}static get pluginName(){return"EntityEmbedToolbar"}}class s extends t.Plugin{static get requires(){return["Widget"]}init(){const t=this.editor,e=t.commands.get("insertEntityEmbed"),i=t.config.get("entityEmbed");t.editing.view.document;if(!i)return;const{dialogSettings:r={}}=i,n=i.buttons;Object.keys(n).forEach((o=>{let d=Drupal.url("entity-embed/dialog/"+i.format+"/"+o);t.ui.componentFactory.add(o,(i=>{let s=n[o],l=new a.ButtonView(i),u=null;if(s.icon.endsWith("svg")){let t=new XMLHttpRequest;t.open("GET",s.icon,!1),t.send(null),u=t.response}return l.set({label:s.label,icon:u??'\n\n \n\n',tooltip:!0}),l.bind("isOn","isEnabled").to(e,"value","isEnabled"),this.listenTo(l,"execute",(()=>Drupal.ckeditor5.openDialog(d,(({attributes:e})=>{t.execute("insertEntityEmbed",e)}),r))),l}))}))}static get pluginName(){return"EntityEmbedUI"}}class l extends t.Plugin{static get requires(){return[o,s,d]}static get pluginName(){return"EntityEmbed"}}const u={EntityEmbed:l}})(),r=r.default})())); \ No newline at end of file diff --git a/js/ckeditor5_plugins/drupalentity/entity.svg b/js/ckeditor5_plugins/drupalentity/entity.svg new file mode 100644 index 0000000..5c6a180 --- /dev/null +++ b/js/ckeditor5_plugins/drupalentity/entity.svg @@ -0,0 +1,9 @@ + + + + diff --git a/js/ckeditor5_plugins/drupalentity/src/command.js b/js/ckeditor5_plugins/drupalentity/src/command.js new file mode 100644 index 0000000..362e7d4 --- /dev/null +++ b/js/ckeditor5_plugins/drupalentity/src/command.js @@ -0,0 +1,52 @@ +import {Command} from 'ckeditor5/src/core'; + +export default class InsertEntityEmbedCommand extends Command { + + execute(attributes) { + const { model } = this.editor; + const entityEmbedEditing = this.editor.plugins.get('EntityEmbedEditing'); + + // Create object that contains supported data-attributes in view data by + // flipping `EntityEmbedEditing.attrs` object (i.e. keys from object become + // values and values from object become keys). + const dataAttributeMapping = Object.entries(entityEmbedEditing.attrs).reduce( + (result, [key, value]) => { + result[value] = key; + return result; + }, + {}, + ); + + // \Drupal\entity_embed\Form\EntityEmbedDialog returns data in keyed by + // data-attributes used in view data. This converts data-attribute keys to + // keys used in model. + const modelAttributes = Object.keys(attributes).reduce( + (result, attribute) => { + if (dataAttributeMapping[attribute]) { + result[dataAttributeMapping[attribute]] = attributes[attribute]; + } + return result; + }, + {}, + ); + + model.change((writer) => { + model.insertContent(createEntityEmbed(writer, modelAttributes)); + }); + } + + refresh() { + const model = this.editor.model; + const selection = model.document.selection; + const allowedIn = model.schema.findAllowedParent( + selection.getFirstPosition(), + 'drupalEntity', + ); + this.isEnabled = allowedIn !== null; + }; + +} + +function createEntityEmbed(writer, attributes) { + return writer.createElement('drupalEntity', attributes); +} diff --git a/js/ckeditor5_plugins/drupalentity/src/editing.js b/js/ckeditor5_plugins/drupalentity/src/editing.js new file mode 100644 index 0000000..6feee86 --- /dev/null +++ b/js/ckeditor5_plugins/drupalentity/src/editing.js @@ -0,0 +1,288 @@ +import { Plugin } from 'ckeditor5/src/core'; +import { Widget, toWidget } from 'ckeditor5/src/widget'; +import InsertEntityEmbedCommand from './command'; + +export default class EntityEmbedEditing extends Plugin { + + /** + * @inheritdoc + */ + static get requires() { + return [Widget]; + } + + /** + * @inheritdoc + */ + init() { + this.attrs = { + alt: 'alt', + title: 'title', + dataCaption: 'data-caption', + dataAlign: 'data-align', + drupalEntityLangCode: 'data-langcode', + drupalEntityEntityType: 'data-entity-type', + drupalEntityEntityUuid: 'data-entity-uuid', + drupalEntityEmbedButton: 'data-embed-button', + drupalEntityEmbedDisplay: 'data-entity-embed-display', + drupalEntityEmbedDisplaySettings: 'data-entity-embed-display-settings', + }; + const options = this.editor.config.get('entityEmbed'); + if (!options) { + return; + } + this.options = options; + this.labelError = Drupal.t('Preview failed'); + this.previewError =` +

${Drupal.t( + 'An error occurred while trying to preview the embedded content. Please save your work and reload this page.', + )}

+ `; + + this._defineSchema(); + this._defineConverters(); + this.editor.commands.add( + 'insertEntityEmbed', + new InsertEntityEmbedCommand(this.editor), + ); + } + + /** + * Registers drupalEntity as a block element in the DOM. + * + * @private + */ + _defineSchema() { + const schema = this.editor.model.schema; + + schema.register('drupalEntity', { + isObject: true, + isContent: true, + isBlock: true, + allowWhere: '$block', + allowAttributes: Object.keys(this.attrs), + }); + this.editor.editing.view.domConverter.blockElements.push('drupal-entity'); + } + + /** + * Defines handling of drupalEntity elements. + * + * @private + */ + _defineConverters() { + const {conversion} = this.editor; + + conversion + .for('upcast') + .elementToElement({ + model: 'drupalEntity', + view: { + name: 'drupal-entity', + }, + }); + + conversion + .for('dataDowncast') + .elementToElement({ + model: 'drupalEntity', + view: { + name: 'drupal-entity', + }, + }); + + // Convert the model into an editable widget. + conversion + .for('editingDowncast') + .elementToElement({ + model: 'drupalEntity', + view: (modelElement, { writer }) => { + const container = writer.createContainerElement('figure', { + class: 'drupal-entity', + }); + writer.setCustomProperty('drupalEntity', true, container); + + return toWidget(container, writer, { + label: Drupal.t('Entity Embed widget'), + }) + }, + }) + .add((dispatcher) => { + const converter = (event, data, conversionApi) => { + const viewWriter = conversionApi.writer; + const modelElement = data.item; + const container = conversionApi.mapper.toViewElement(data.item); + + let drupalEntity = this._getPreviewContainer(container.getChildren()); + // Use existing container if it exists, create on if it does not. + if (drupalEntity) { + // Stop processing if a preview is already loading. + if (drupalEntity.getAttribute('data-drupal-entity-preview') !== 'ready') { + return; + } + // Preview was ready meaning that a new preview can be loaded. + // Change the attribute to loading to prepare for the loading of + // the updated preview. Preview is kept intact so that it remains + // interactable in the UI until the new preview has been rendered. + viewWriter.setAttribute( + 'data-drupal-entity-preview', + 'loading', + drupalEntity, + ); + } + else { + drupalEntity = viewWriter.createRawElement('div', { + 'data-drupal-entity-preview': 'loading', + }); + viewWriter.insert(viewWriter.createPositionAt(container, 0), drupalEntity); + } + + this._loadPreview(modelElement).then(({ label, preview }) => { + if (!drupalEntity) { + // Nothing to do if associated preview wrapped no longer exist. + return; + } + // CKEditor 5 doesn't support async view conversion. Therefore, once + // the promise is fulfilled, the editing view needs to be modified + // manually. + this.editor.editing.view.change((writer) => { + const drupalEntityPreview = writer.createRawElement( + 'div', + {'data-drupal-entity-preview': 'ready', 'aria-label': label}, + (domElement) => { + domElement.innerHTML = preview; + }, + ); + // Insert the new preview before the previous preview element to + // ensure that the location remains same even if it is wrapped + // with another element. + writer.insert(writer.createPositionBefore(drupalEntity), drupalEntityPreview); + writer.remove(drupalEntity); + }); + }); + } + + dispatcher.on('attribute:drupalEntityEntityUuid:drupalEntity', converter); + + return dispatcher; + }); + + // Set attributeToAttribute conversion for all supported attributes. + Object.keys(this.attrs).forEach((modelKey) => { + const attributeMapping = { + model: { + key: modelKey, + name: 'drupalEntity', + }, + view: { + name: 'drupal-entity', + key: this.attrs[modelKey], + }, + }; + // Attributes should be rendered only in dataDowncast to avoid having + // unfiltered data-attributes on the Drupal Entity widget. + conversion.for('dataDowncast').attributeToAttribute(attributeMapping); + conversion.for('upcast').attributeToAttribute(attributeMapping); + }); + } + + /** + * Loads the preview. + * + * @param modelElement + * The model element which preview should be loaded. + * @returns {Promise<{preview: string, label: *}|{preview: *, label: string}>} + * A promise that returns an object. + * + * @private + * + * @see DrupalMediaEditing::_fetchPreview(). + */ + async _loadPreview(modelElement) { + const query = { + text: this._renderElement(modelElement), + }; + + const response = await fetch( + Drupal.url('embed/preview/' + this.options.format + '?' + new URLSearchParams(query)), + { + headers: { + 'X-Drupal-EmbedPreview-CSRF-Token': + this.options.previewCsrfToken, + }, + }, + ); + + if (response.ok) { + const label = Drupal.t('Entity Embed widget'); + const preview = await response.text(); + return { label, preview }; + } + + return { label: this.labelError, preview: this.previewError }; + } + + /** + * Renders the model element. + * + * @param modelElement + * The drupalMedia model element to be converted. + * @returns {*} + * The model element converted into HTML. + * + * @private + */ + _renderElement(modelElement) { + // Create model document fragment which contains the model element so that + // it can be stringified using the dataDowncast. + const modelDocumentFragment = this.editor.model.change((writer) => { + const modelDocumentFragment = writer.createDocumentFragment(); + // Create shallow clone of the model element to ensure that the original + // model element remains untouched and that the caption is not rendered + // into the preview. + const clonedModelElement = writer.cloneElement(modelElement, false); + writer.append(clonedModelElement, modelDocumentFragment); + + return modelDocumentFragment; + }); + + return this.editor.data.stringify(modelDocumentFragment); + } + + /** + * Gets the preview container element. + * + * @param children + * The child elements. + * @returns {null|*} + * The preview child element if available. + * + * @private + */ + _getPreviewContainer(children) { + for (const child of children) { + if (child.hasAttribute('data-drupal-entity-preview')) { + return child; + } + + if (child.childCount) { + const recursive = this._getPreviewContainer(child.getChildren()); + // Return only if preview container was found within this element's + // children. + if (recursive) { + return recursive; + } + } + } + + return null; + } + + /** + * @inheritdoc + */ + static get pluginName() { + return 'EntityEmbedEditing'; + } + +} diff --git a/js/ckeditor5_plugins/drupalentity/src/entityembed.js b/js/ckeditor5_plugins/drupalentity/src/entityembed.js new file mode 100644 index 0000000..65fad2a --- /dev/null +++ b/js/ckeditor5_plugins/drupalentity/src/entityembed.js @@ -0,0 +1,19 @@ +import EntityEmbedEditing from './editing'; +import EntityEmbedToolbar from './toolbar'; +import EntityEmbedUI from './ui'; +import { Plugin } from 'ckeditor5/src/core'; + +export default class EntityEmbed extends Plugin { + + static get requires() { + return [EntityEmbedEditing, EntityEmbedUI, EntityEmbedToolbar]; + } + + /** + * @inheritdoc + */ + static get pluginName() { + return 'EntityEmbed'; + } + +} diff --git a/js/ckeditor5_plugins/drupalentity/src/index.js b/js/ckeditor5_plugins/drupalentity/src/index.js new file mode 100644 index 0000000..f7197a1 --- /dev/null +++ b/js/ckeditor5_plugins/drupalentity/src/index.js @@ -0,0 +1,9 @@ +/** + * @module entity-embed + */ + +import EntityEmbed from './entityembed'; + +export default { + EntityEmbed, +}; diff --git a/js/ckeditor5_plugins/drupalentity/src/toolbar.js b/js/ckeditor5_plugins/drupalentity/src/toolbar.js new file mode 100644 index 0000000..dc0c27f --- /dev/null +++ b/js/ckeditor5_plugins/drupalentity/src/toolbar.js @@ -0,0 +1,135 @@ +import { Plugin, icons } from 'ckeditor5/src/core'; +import { isWidget, WidgetToolbarRepository } from 'ckeditor5/src/widget'; +import { ButtonView } from "ckeditor5/src/ui"; + +export default class EntityEmbedToolbar extends Plugin { + + /** + * @inheritdoc + */ + static get requires() { + return [WidgetToolbarRepository]; + } + + /** + * @inheritdoc + */ + init() { + const editor = this.editor; + const entityEmbedEditing = editor.plugins.get('EntityEmbedEditing'); + const options = editor.config.get('entityEmbed'); + const { dialogSettings = {} } = options; + + editor.ui.componentFactory.add('entityEmbedEdit', (locale) => { + let buttonView = new ButtonView(locale); + + buttonView.set({ + label: editor.t('Edit'), + icon: icons.pencil, + tooltip: true, + }) + + this.listenTo(buttonView, 'execute', (eventInfo) => { + let element = editor.model.document.selection.getSelectedElement(); + let libraryURL = Drupal.url('entity-embed/dialog/' + options.format + '/' + element.getAttribute('drupalEntityEmbedButton')); + + let existingValues = {}; + + for (let [key, value] of element.getAttributes()) { + let attribute = entityEmbedEditing.attrs[key] + if (attribute) { + existingValues[attribute] = value + } + } + + // Open a dialog to select entity to embed. + this._openDialog( + libraryURL, + existingValues, + ({ attributes }) => { + editor.execute('insertEntityEmbed', attributes); + editor.editing.view.focus(); + }, + dialogSettings, + ) + }); + + return buttonView; + }) + } + + /** + * @inheritdoc + */ + afterInit() { + const { editor } = this + if (!editor.plugins.has('WidgetToolbarRepository')) { + return; + } + const widgetToolbarRepository = editor.plugins.get('WidgetToolbarRepository') + + widgetToolbarRepository.register('entityEmbed', { + items: ['entityEmbedEdit'], + getRelatedElement(selection) { + const viewElement = selection.getSelectedElement() + if (!viewElement) { + return null + } + if (!isWidget(viewElement)) { + return null + } + if (!viewElement.getCustomProperty('drupalEntity')) { + return null + } + + return viewElement + }, + }) + } + + /** + * @param {string} url + * The URL that contains the contents of the dialog. + * @param {object} existingValues + * Existing values that will be sent via POST to the url for the dialog + * contents. + * @param {function} saveCallback + * A function to be called upon saving the dialog. + * @param {object} dialogSettings + * An object containing settings to be passed to the jQuery UI. + */ + _openDialog(url, existingValues, saveCallback, dialogSettings) { + // Add a consistent dialog class. + const classes = dialogSettings.dialogClass + ? dialogSettings.dialogClass.split(' ') + : []; + classes.push('ui-dialog--narrow'); + dialogSettings.dialogClass = classes.join(' '); + dialogSettings.autoResize = + window.matchMedia('(min-width: 600px)').matches; + dialogSettings.width = 'auto'; + + const ckeditorAjaxDialog = Drupal.ajax({ + dialog: dialogSettings, + dialogType: 'modal', + selector: '.ckeditor5-dialog-loading-link', + url, + progress: { type: 'fullscreen' }, + submit: { + editor_object: existingValues, + }, + }); + ckeditorAjaxDialog.execute(); + + // Store the save callback to be executed when this dialog is closed. + Drupal.ckeditor5.saveCallback = saveCallback; + } + + /** + * @inheritdoc + */ + static get pluginName() { + return 'EntityEmbedToolbar'; + } + +} diff --git a/js/ckeditor5_plugins/drupalentity/src/ui.js b/js/ckeditor5_plugins/drupalentity/src/ui.js new file mode 100644 index 0000000..16a7cc5 --- /dev/null +++ b/js/ckeditor5_plugins/drupalentity/src/ui.js @@ -0,0 +1,79 @@ +/** + * @file Registers the entity embed button(s) to the CKEditor instance(s) and binds functionality to it/them. + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { ButtonView } from 'ckeditor5/src/ui'; +import defaultIcon from '../entity.svg'; + +export default class EntityEmbedUI extends Plugin { + + /** + * @inheritdoc + */ + static get requires() { + return ['Widget']; + } + + /** + * @inheritdoc + */ + init() { + const editor = this.editor; + const command = editor.commands.get('insertEntityEmbed'); + const options = editor.config.get('entityEmbed'); + const viewDocument = editor.editing.view.document; + if (!options) { + return; + } + const { dialogSettings = {} } = options; + const embed_buttons = options.buttons; + + // Register each embed button to the toolbar based on configuration. + Object.keys(embed_buttons).forEach(id => { + let libraryURL = Drupal.url('entity-embed/dialog/' + options.format + '/' + id); + // Add each button to the toolbar. + editor.ui.componentFactory.add(id, (locale) => { + let button = embed_buttons[id]; + let buttonView = new ButtonView(locale); + // Set the icon to the SVG from config, or set it to the default icon. + // If the uploaded icon is an SVG, load it or use the default icon otherwise. + let icon = null; + if (button.icon.endsWith('svg')) { + let XMLrequest = new XMLHttpRequest(); + XMLrequest.open("GET", button.icon, false); + XMLrequest.send(null); + icon = XMLrequest.response; + } + + buttonView.set({ + label: button.label, + icon: icon ?? defaultIcon, + tooltip: true, + }); + buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled'); + + this.listenTo(buttonView, 'execute', () => + // Open a dialog to select entity to embed. + Drupal.ckeditor5.openDialog( + libraryURL, + ({ attributes }) => { + editor.execute('insertEntityEmbed', attributes); + }, + dialogSettings, + ), + ); + + return buttonView; + }) + }); + } + + /** + * @inheritdoc + */ + static get pluginName() { + return 'EntityEmbedUI'; + } + +} diff --git a/js/entity_embed.set_dynamic_icons.js b/js/entity_embed.set_dynamic_icons.js new file mode 100644 index 0000000..cfca00e --- /dev/null +++ b/js/entity_embed.set_dynamic_icons.js @@ -0,0 +1,18 @@ +/* This script is responsible for setting the correct image(s) on the admin + * toolbar, as we cannot build the JS for it because we do not know how + * many entity embed buttons are there in the system. The number of buttons + * created are based on the number of embed buttons. + */ +(function ($, Drupal, drupalSettings, once) { + Drupal.behaviors.entityEmbedSetDynamicIcons = { + attach: function (context) { + // Get the available Embed Buttons from Drupal. + Object.values(drupalSettings.embedButtons || {}).forEach(function (button) { + // Iterate through the embed buttons and set the corresponding background image. + let selector = '.ckeditor5-toolbar-button-' + button.id; + let iconUrl = button.icon.endsWith('svg') ? button.icon : '/' + drupalSettings.modulePath + '/js/ckeditor5_plugins/drupalentity/entity.svg'; + $(once('entityEmbedSetDynamicIcons', selector, context)).css('background-image', 'url(' + iconUrl + ')'); + }); + }, + } +})(jQuery, Drupal, drupalSettings, once); diff --git a/package.json b/package.json new file mode 100644 index 0000000..2690528 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "drupal-entity-embed", + "version": "1.0.0", + "description": "Drupal Entity Embed CKEditor 5 integration", + "author": "", + "license": "GPL-2.0-or-later", + "scripts": { + "watch": "webpack --mode development --watch", + "build": "webpack" + }, + "devDependencies": { + "@ckeditor/ckeditor5-dev-utils": "^30.0.0", + "ckeditor5": "~34.1.0", + "raw-loader": "^4.0.2", + "terser-webpack-plugin": "^5.2.0", + "webpack": "^5.51.1", + "webpack-cli": "^4.4.0" + } +} diff --git a/src/Plugin/CKEditor4To5Upgrade/EntityEmbed.php b/src/Plugin/CKEditor4To5Upgrade/EntityEmbed.php new file mode 100644 index 0000000..1628b67 --- /dev/null +++ b/src/Plugin/CKEditor4To5Upgrade/EntityEmbed.php @@ -0,0 +1,62 @@ +getStorage('embed_button') + ->loadMultiple(); + foreach ($embed_buttons as $embed_button) { + $buttons[] = $embed_button->id(); + } + foreach ($buttons as $button) { + if ($cke4_button == $button) { + return [$button]; + } + } + + throw new \OutOfBoundsException(); + } + + /** + * {@inheritdoc} + */ + public function mapCKEditor4SettingsToCKEditor5Configuration(string $cke4_plugin_id, array $cke4_plugin_settings): ?array { + throw new \OutOfBoundsException(); + } + + /** + * {@inheritdoc} + */ + public function computeCKEditor5PluginSubsetConfiguration(string $cke5_plugin_id, FilterFormatInterface $text_format): ?array { + throw new \OutOfBoundsException(); + } +} diff --git a/src/Plugin/CKEditor5Plugin/DrupalEntity.php b/src/Plugin/CKEditor5Plugin/DrupalEntity.php new file mode 100644 index 0000000..a09d9d3 --- /dev/null +++ b/src/Plugin/CKEditor5Plugin/DrupalEntity.php @@ -0,0 +1,129 @@ +", + * "", + * }, + * conditions = { + * "filter" = "entity_embed", + * }, + * ) + * ) + */ +class DrupalEntity extends CKEditor5PluginDefault implements ContainerFactoryPluginInterface { + + /** + * The CSRF Token generator. + * + * @var \Drupal\Core\Access\CsrfTokenGenerator + */ + protected $csrfTokenGenerator; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * DrupalEntity constructor. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param \Drupal\ckeditor5\Plugin\CKEditor5PluginDefinition $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token_generator + * The CSRF Token generator service. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The Entity Type Manager service. + */ + public function __construct( + array $configuration, + string $plugin_id, + CKEditor5PluginDefinition $plugin_definition, + CsrfTokenGenerator $csrf_token_generator, + EntityTypeManagerInterface $entity_type_manager + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->csrfTokenGenerator = $csrf_token_generator; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritDoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('csrf_token'), + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getDynamicPluginConfig(array $static_plugin_config, EditorInterface $editor): array { + // Register embed buttons as individual buttons on admin pages. + $dynamic_plugin_config = $static_plugin_config; + $embed_buttons = $this + ->entityTypeManager + ->getStorage('embed_button') + ->loadMultiple(); + $buttons = []; + /** @var \Drupal\embed\EmbedButtonInterface $embed_button */ + foreach ($embed_buttons as $embed_button) { + $id = $embed_button->id(); + $label = Html::escape($embed_button->label()); + $buttons[$id] = [ + 'id' => $id, + 'name' => $label, + 'label' => $label, + 'icon' => $embed_button->getIconUrl(), + ]; + } + // Add configured embed buttons and pass it to the UI. + $dynamic_plugin_config['entityEmbed'] = [ + 'buttons' => $buttons, + 'format' => $editor->getFilterFormat()->id(), + 'dialogSettings' => [ + 'dialogClass' => 'entity-select-dialog', + 'height' => 'auto', + 'width' => 'auto', + ], + 'previewCsrfToken' => $this->csrfTokenGenerator->get('X-Drupal-EmbedPreview-CSRF-Token'), + ]; + + return $dynamic_plugin_config; + } + +} diff --git a/src/Plugin/CKEditor5Plugin/DrupalEntityDeriver.php b/src/Plugin/CKEditor5Plugin/DrupalEntityDeriver.php new file mode 100644 index 0000000..ffbe8e7 --- /dev/null +++ b/src/Plugin/CKEditor5Plugin/DrupalEntityDeriver.php @@ -0,0 +1,74 @@ +embedButtonStorage = $entity_type_manager->getStorage('embed_button'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + assert($base_plugin_definition instanceof CKEditor5PluginDefinition); + /** @var \Drupal\embed\EmbedButtonInterface $embed_button */ + foreach ($this->embedButtonStorage->loadMultiple() as $embed_button) { + $embed_button_id = $embed_button->id(); + $embed_button_label = Html::escape($embed_button->label()); + $plugin_id = "entity_embed_{$embed_button_id}"; + $definition = $base_plugin_definition->toArray(); + $definition['id'] .= $embed_button_id; + $definition['drupal']['label'] = $this->t('Entity Embed - @label', ['@label' => $embed_button_label])->render(); + $definition['drupal']['toolbar_items'] = [ + $embed_button_id => [ + 'label' => $embed_button_label, + ], + ]; + $definition['drupal']['elements'][] = ''; + $definition['drupal']['elements'][] = ''; + $this->derivatives[$plugin_id] = new CKEditor5PluginDefinition($definition); + } + + return $this->derivatives; + } + +} diff --git a/src/Plugin/Derivative/DrupalEntityDeriver.php b/src/Plugin/Derivative/DrupalEntityDeriver.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/modules/entity_embed_test/entity_embed_test.info.yml b/tests/modules/entity_embed_test/entity_embed_test.info.yml index d7ffa6d..906b185 100644 --- a/tests/modules/entity_embed_test/entity_embed_test.info.yml +++ b/tests/modules/entity_embed_test/entity_embed_test.info.yml @@ -12,3 +12,4 @@ dependencies: - drupal:editor - embed:embed - entity_embed:entity_embed + - ckeditor5:ckeditor5 diff --git a/tests/modules/entity_embed_translation_test/entity_embed_translation_test.info.yml b/tests/modules/entity_embed_translation_test/entity_embed_translation_test.info.yml index e28ea6d..e8e4753 100644 --- a/tests/modules/entity_embed_translation_test/entity_embed_translation_test.info.yml +++ b/tests/modules/entity_embed_translation_test/entity_embed_translation_test.info.yml @@ -13,3 +13,4 @@ dependencies: - drupal:editor - embed:embed - entity_embed:entity_embed + - ckeditor5:ckeditor5 diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..bd63f7f --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,65 @@ +const path = require('path'); +const fs = require('fs'); +const webpack = require('webpack'); +const TerserPlugin = require('terser-webpack-plugin'); + +function getDirectories(srcpath) { + return fs + .readdirSync(srcpath) + .filter((item) => fs.statSync(path.join(srcpath, item)).isDirectory()); +} + +module.exports = []; +// Loop through every subdirectory in src, each a different plugin, and build +// each one in ./build. +getDirectories('./js/ckeditor5_plugins').forEach((dir) => { + const bc = { + mode: 'production', + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + format: { + comments: false, + }, + }, + test: /\.js(\?.*)?$/i, + extractComments: false, + }), + ], + moduleIds: 'named', + }, + entry: { + path: path.resolve( + __dirname, + 'js/ckeditor5_plugins', + dir, + 'src/index.js', + ), + }, + output: { + path: path.resolve(__dirname, './js/build'), + filename: `${dir}.js`, + library: ['CKEditor5', dir], + libraryTarget: 'umd', + libraryExport: 'default', + }, + plugins: [ + // It is possible to require the ckeditor5-dll.manifest.json used in + // core/node_modules rather than having to install CKEditor 5 here. + // However, that requires knowing the location of that file relative to + // where your module code is located. + new webpack.DllReferencePlugin({ + manifest: require('./node_modules/ckeditor5/build/ckeditor5-dll.manifest.json'), // eslint-disable-line global-require, import/no-unresolved + scope: 'ckeditor5/src', + name: 'CKEditor5.dll', + }), + ], + module: { + rules: [{ test: /\.svg$/, use: 'raw-loader' }], + }, + }; + + module.exports.push(bc); +});