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',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);
+});