entity_embed-8.x-1.x-dev/js/ckeditor5_plugins/drupalentity/src/linkediting.js
js/ckeditor5_plugins/drupalentity/src/linkediting.js
/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words linkediting linkimageediting linkcommand */
import { Plugin } from 'ckeditor5/src/core';
import { Matcher } from 'ckeditor5/src/engine';
import { toMap } from 'ckeditor5/src/utils';
/**
* Returns the first drupal-entity element in a given view element.
*
* @param {module:engine/view/element~Element} viewElement
* The view element.
*
* @return {module:engine/view/element~Element|undefined}
* The first <drupal-entity> element or undefined if the element doesn't have
* <drupal-entity> as a child element.
*/
function getFirstEntityEmbed(viewElement) {
return Array.from(viewElement.getChildren()).find(
(child) => child.name === 'drupal-entity'
);
}
/**
* Returns a converter that consumes the `href` attribute if a link contains a <drupal-entity>.
*
* @return {Function}
* A function that adds an event listener to upcastDispatcher.
*/
function upcastEntityEmbedLink() {
return (dispatcher) => {
dispatcher.on(
'element:a',
(evt, data, conversionApi) => {
const viewLink = data.viewItem;
const entityEmbedInLink = getFirstEntityEmbed(viewLink);
if (!entityEmbedInLink) {
return;
}
// There's an <drupal-entity> inside an <a> element - we consume it so it
// won't be picked up by the Link plugin.
const consumableAttributes = { attributes: ['href'], name: true };
// Consume the `href` attribute so the default one will not convert it to
// $text attribute.
if (!conversionApi.consumable.consume(viewLink, consumableAttributes)) {
// Might be consumed by something else - i.e. other converter with
// priority=highest - a standard check.
return;
}
const linkHref = viewLink.getAttribute('href');
// Missing the `href` attribute.
if (!linkHref) {
return;
}
const conversionResult = conversionApi.convertItem(
entityEmbedInLink,
data.modelCursor
);
// Set entity embed range as conversion result.
data.modelRange = conversionResult.modelRange;
// Continue conversion where <drupal-entity> conversion ends.
data.modelCursor = conversionResult.modelCursor;
const modelElement = data.modelCursor.nodeBefore;
if (modelElement && modelElement.is('element', 'drupalEntity')) {
// Set the `linkHref` attribute from <a> element on model drupalEntity
// element.
conversionApi.writer.setAttribute('linkHref', linkHref, modelElement);
}
},
{ priority: 'high' }
);
};
}
/**
* Return a converter that adds the <a> element to view data.
*
* @return {Function}
* A function that adds an event listener to downcastDispatcher.
*/
function dataDowncastEntityEmbedLink() {
return (dispatcher) => {
dispatcher.on(
'attribute:linkHref:drupalEntity',
(evt, data, conversionApi) => {
const { writer } = conversionApi;
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
// The drupalEntity will be already converted - so it will be present in
// the view.
const entityEmbedElement = conversionApi.mapper.toViewElement(
data.item
);
// If so, update the attribute if it's defined or remove the entire link
// if the attribute is empty. But if it does not exist. Let's wrap already
// converted drupalEntity by newly created link element.
// 1. Create an empty <a> element.
const linkElement = writer.createContainerElement('a', {
href: data.attributeNewValue,
});
// 2. Insert <a> before the <drupal-entity> element.
writer.insert(
writer.createPositionBefore(entityEmbedElement),
linkElement
);
// 3. Move the drupal-entity element inside the <a>.
writer.move(
writer.createRangeOn(entityEmbedElement),
writer.createPositionAt(linkElement, 0)
);
},
{ priority: 'high' }
);
};
}
/**
* Return a converter that adds the <a> element to editing view.
*
* @return {Function}
* A function that adds an event listener to downcastDispatcher.
*
* @see https://github.com/ckeditor/ckeditor5/blob/v31.0.0/packages/ckeditor5-link/src/linkimageediting.js#L180
*/
function editingDowncastEntityEmbedLink() {
return (dispatcher) => {
dispatcher.on(
'attribute:linkHref:drupalEntity',
(evt, data, conversionApi) => {
const { writer } = conversionApi;
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
// The drupalEntity will be already converted - so it will be present in
// the view.
const entityEmbedContainer = conversionApi.mapper.toViewElement(
data.item
);
const linkInEntityEmbed = Array.from(
entityEmbedContainer.getChildren()
).find((child) => child.name === 'a');
// If link already exists, instead of creating new link from scratch,
// update the existing link. This makes the UI rendering much smoother.
if (linkInEntityEmbed) {
// If attribute has a new value, update it. If new value doesn't exist,
// the link will be removed.
if (data.attributeNewValue) {
writer.setAttribute(
'href',
data.attributeNewValue,
linkInEntityEmbed
);
} else {
// This is triggering elementToElement conversion for drupalEntity
// element which makes caused re-render of the entity embed preview, making
// the entity embed preview flicker once when entity embed is unlinked.
// @todo ensure that this doesn't cause flickering after
// https://www.drupal.org/i/3304834 has been addressed.
writer.move(
writer.createRangeIn(linkInEntityEmbed),
writer.createPositionAt(entityEmbedContainer, 0)
);
writer.remove(linkInEntityEmbed);
}
} else {
const entityEmbedPreview = Array.from(
entityEmbedContainer.getChildren()
).find((child) => child.getAttribute('data-drupal-entity-preview'));
// 1. Create an empty <a> element.
const linkElement = writer.createContainerElement('a', {
href: data.attributeNewValue,
});
// 2. Insert <a> inside the entity embed container.
writer.insert(
writer.createPositionAt(entityEmbedContainer, 0),
linkElement
);
// 3. Move the entity embed preview inside the <a>.
writer.move(
writer.createRangeOn(entityEmbedPreview),
writer.createPositionAt(linkElement, 0)
);
}
},
{ priority: 'high' }
);
};
}
/**
* Returns a converter that enables manual decorators on linked Drupal Entity.
*
* @see \Drupal\editor\EditorXssFilter\Standard
*
* @param {module:link/link~LinkDecoratorDefinition} decorator
* The link decorator.
* @return {function}
* Function attaching event listener to dispatcher.
*
* @private
*/
function downcastEntityEmbedLinkManualDecorator(decorator) {
return (dispatcher) => {
dispatcher.on(
`attribute:${decorator.id}:drupalEntity`,
(evt, data, conversionApi) => {
const entityEmbedContainer = conversionApi.mapper.toViewElement(
data.item
);
// Scenario 1: `<figure>` element that contains `<a>`, generated by
// `dataDowncast`.
let entityEmbedLink = Array.from(
entityEmbedContainer.getChildren()
).find((child) => child.name === 'a');
// Scenario 2: `<drupal-entity>` wrapped with `<a>`, generated by
// `editingDowncast`.
if (!entityEmbedLink && entityEmbedContainer.is('element', 'a')) {
entityEmbedLink = entityEmbedContainer;
} else {
entityEmbedLink = Array.from(
entityEmbedContainer.getAncestors()
).find((ancestor) => ancestor.name === 'a');
}
// The <a> element was removed by the time this converter is executed.
// It may happen when the base `linkHref` and decorator attributes are
// removed at the same time.
if (!entityEmbedLink) {
return;
}
// eslint-disable-next-line no-restricted-syntax
for (const [key, val] of toMap(decorator.attributes)) {
conversionApi.writer.setAttribute(key, val, entityEmbedLink);
}
if (decorator.classes) {
conversionApi.writer.addClass(decorator.classes, entityEmbedLink);
}
// Add support for `style` attribute in manual decorators to remain
// consistent with CKEditor 5. This only works with text formats that
// have no HTMl filtering enabled.
// eslint-disable-next-line no-restricted-syntax
for (const key in decorator.styles) {
if (Object.prototype.hasOwnProperty.call(decorator.styles, key)) {
conversionApi.writer.setStyle(
key,
decorator.styles[key],
entityEmbedLink
);
}
}
}
);
};
}
/**
* Returns a converter that applies manual decorators to linked Drupal Entity.
*
* @param {module:core/editor/editor~Editor} editor
* The editor.
* @param {module:link/link~LinkDecoratorDefinition} decorator
* The link decorator.
* @return {function}
* Function attaching event listener to dispatcher.
*
* @private
*/
function upcastEntityEmbedLinkManualDecorator(editor, decorator) {
return (dispatcher) => {
dispatcher.on(
'element:a',
(evt, data, conversionApi) => {
const viewLink = data.viewItem;
const drupalEntityEmbedInLink = getFirstEntityEmbed(viewLink);
// We need to check whether Drupal Entity is inside a link because the
// converter handles only manual decorators for linked Drupal Entity.
if (!drupalEntityEmbedInLink) {
return;
}
const matcher = new Matcher(decorator._createPattern());
const result = matcher.match(viewLink);
// The link element does not have required attributes or/and proper
// values.
if (!result) {
return;
}
// Check whether we can consume those attributes.
if (!conversionApi.consumable.consume(viewLink, result.match)) {
return;
}
// At this stage we can assume that we have the `<drupalEntity>` element.
const modelElement = data.modelCursor.nodeBefore;
conversionApi.writer.setAttribute(decorator.id, true, modelElement);
},
{ priority: 'high' }
);
// Using the same priority as the entity embed link upcast converter guarantees
// that the linked `<drupalEntity>` was already converted.
// @see upcastEntityEmbedLink().
};
}
/**
* Model to view and view to model conversions for linked entity embed elements.
*
* @private
*
* @see https://github.com/ckeditor/ckeditor5/blob/v31.0.0/packages/ckeditor5-link/src/linkimage.js
*/
export default class EntityEmbedLinkEditing extends Plugin {
/**
* @inheritdoc
*/
static get requires() {
return ['EntityEmbedEditing'];
}
/**
* @inheritdoc
*/
static get pluginName() {
return 'EntityEmbedLinkEditing';
}
/**
* @inheritdoc
*/
init() {
const { editor } = this;
editor.model.schema.extend('drupalEntity', {
allowAttributes: ['linkHref'],
});
editor.conversion.for('upcast').add(upcastEntityEmbedLink());
editor.conversion
.for('editingDowncast')
.add(editingDowncastEntityEmbedLink());
editor.conversion.for('dataDowncast').add(dataDowncastEntityEmbedLink());
this._enableManualDecorators();
const linkCommand = editor.commands.get('link');
if (linkCommand.automaticDecorators.length > 0) {
throw new Error(
'The Drupal Entity plugin is not compatible with automatic link decorators. To use Drupal Entity, disable any plugins providing automatic link decorators.'
);
}
}
/**
* Processes transformed manual link decorators and attaches proper converters
* that will work when linking Drupal Entity.
*
* @see module:link/linkimageediting~LinkImageEditing
* @see module:link/linkcommand~LinkCommand
* @see module:link/utils~ManualDecorator
*
* @private
*/
_enableManualDecorators() {
const editor = this.editor;
const command = editor.commands.get('link');
// eslint-disable-next-line no-restricted-syntax
for (const decorator of command.manualDecorators) {
editor.model.schema.extend('drupalEntity', {
allowAttributes: decorator.id,
});
editor.conversion
.for('downcast')
.add(downcastEntityEmbedLinkManualDecorator(decorator));
editor.conversion
.for('upcast')
.add(upcastEntityEmbedLinkManualDecorator(editor, decorator));
}
}
}
