entity_embed-8.x-1.x-dev/js/ckeditor5_plugins/drupalentity/src/editing.js
js/ckeditor5_plugins/drupalentity/src/editing.js
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',
drupalEntityViewMode: 'data-view-mode',
drupalEntityEmbedButton: 'data-embed-button',
drupalEntityEmbedDisplay: 'data-entity-embed-display',
drupalEntityEmbedDisplaySettings: 'data-entity-embed-display-settings',
};
const options = this.editor.config.get('entityEmbed');
if (!options) {
throw new Error('Error on initializing entityEmbed plugin: entityEmbed config is required.');
}
this.options = options;
this.labelError = Drupal.t('Preview failed');
this.previewError =`
<p>${Drupal.t(
'An error occurred while trying to preview the embedded content. Please save your work and reload this page.',
)}<p>
`;
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;
const elementMapping = {
model: 'drupalEntity',
view: {
name: 'drupal-entity',
},
};
conversion
.for('upcast')
.elementToElement(elementMapping);
conversion
.for('dataDowncast')
.elementToElement(elementMapping);
// Convert the <drupalEntity> model into an editable <drupal-entity> widget.
conversion
.for('editingDowncast')
.elementToElement({
...elementMapping,
view: (modelElement, { writer }) => {
// Align classes should be applied to the wrapper element so the
// alignment is relative to the other editor contents, and not
// within the container.
const alignClass = modelElement.hasAttribute('dataAlign') ? ` align-${modelElement.getAttribute('dataAlign')}` : '';
const container = writer.createContainerElement('figure', {
class: `drupal-entity${alignClass}`,
});
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 is 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 drupalEntity 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);
// Remove attributes from the model element to ensure they are not
// downcast into the preview request.
// - The `linkHref` model attribute would downcast into a wrapping `<a>`
// element, which the preview endpoint would not be able to handle.
// - The dataAlign attribute results in adding an alignment class that
// does not work properly when applied to the preview. The alignment
// class is instead added to the widget container element so alignment
// is relative to the editor contents, and not the widget container.
const attributeIgnoreList = ['linkHref', 'dataAlign'];
attributeIgnoreList.forEach((attribute) => {
writer.removeAttribute(attribute, clonedModelElement);
});
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';
}
}
