@northernco/ckeditor5-anchor-drupal
Version:
Drupal CKEditor 5 integration
277 lines (229 loc) • 9.93 kB
JavaScript
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module anchor/anchorimageediting
*/
import { Plugin } from 'ckeditor5/src/core';
import { ImageEditing } from 'ckeditor5/src/image';
import { Matcher } from 'ckeditor5/src/engine';
import { toMap } from 'ckeditor5/src/utils';
import { AnchorEditing } from './anchorediting';
import anchorIcon from '../theme/icons/anchor.svg';
/**
* The anchor image engine feature.
*
* It accepts the `anchorId="url"` attribute in the model for the {@link module:image/image~Image `<image>`} element
* which allows anchoring images.
*
* @extends module:core/plugin~Plugin
*/
export default class AnchorImageEditing extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [ ImageEditing, AnchorEditing ];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'AnchorImageEditing';
}
init() {
const editor = this.editor;
editor.model.schema.extend( 'image', { allowAttributes: [ 'anchorId' ] } );
editor.conversion.for( 'upcast' ).add( upcastAnchor() );
editor.conversion.for( 'editingDowncast' ).add( downcastImageAnchor( { attachIconIndicator: true } ) );
editor.conversion.for( 'dataDowncast' ).add( downcastImageAnchor( { attachIconIndicator: false } ) );
// Definitions for decorators are provided by the `anchor` command and the `AnchorEditing` plugin.
this._enableAutomaticDecorators();
this._enableManualDecorators();
}
/**
* Processes {@link module:anchor/anchor~AnchorDecoratorAutomaticDefinition automatic decorators} definitions and
* attaches proper converters that will work when anchoring an image.`
*
* @private
*/
_enableAutomaticDecorators() {
const editor = this.editor;
const command = editor.commands.get( 'anchor' );
const automaticDecorators = command.automaticDecorators;
if ( automaticDecorators.length ) {
editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcherForAnchoredImage() );
}
}
/**
* Processes transformed {@link module:anchor/utils~ManualDecorator} instances and attaches proper converters
* that will work when anchoring an image.
*
* @private
*/
_enableManualDecorators() {
const editor = this.editor;
const command = editor.commands.get( 'anchor' );
const manualDecorators = command.manualDecorators;
for ( const decorator of command.manualDecorators ) {
editor.model.schema.extend( 'image', { allowAttributes: decorator.id } );
editor.conversion.for( 'downcast' ).add( downcastImageAnchorManualDecorator( manualDecorators, decorator ) );
editor.conversion.for( 'upcast' ).add( upcastImageAnchorManualDecorator( manualDecorators, decorator ) );
}
}
}
// Returns a converter that consumes the 'id' attribute if a anchor contains an image.
//
// @private
// @returns {Function}
function upcastAnchor() {
return dispatcher => {
dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
const viewAnchor = data.viewItem;
const imageInAnchor = getFirstImage( viewAnchor );
if ( !imageInAnchor ) {
return;
}
// There's an image inside an <a> element - we consume it so it won't be picked up by the Anchor plugin.
const consumableAttributes = { attributes: [ 'id' ] };
// Consume the `id` attribute so the default one will not convert it to $text attribute.
if ( !conversionApi.consumable.consume( viewAnchor, consumableAttributes ) ) {
// Might be consumed by something else - i.e. other converter with priority=highest - a standard check.
return;
}
const anchorId = viewAnchor.getAttribute( 'id' );
// Missing the 'id' attribute.
if ( !anchorId ) {
return;
}
// A full definition of the image feature.
// figure > a > img: parent of the view anchor element is an image element (figure).
let modelElement = data.modelCursor.parent;
if ( !modelElement.is( 'element', 'image' ) ) {
// a > img: parent of the view anchor is not the image (figure) element. We need to convert it manually.
const conversionResult = conversionApi.convertItem( imageInAnchor, data.modelCursor );
// Set image range as conversion result.
data.modelRange = conversionResult.modelRange;
// Continue conversion where image conversion ends.
data.modelCursor = conversionResult.modelCursor;
modelElement = data.modelCursor.nodeBefore;
}
if ( modelElement && modelElement.is( 'element', 'image' ) ) {
// Set the anchorId attribute from anchor element on model image element.
conversionApi.writer.setAttribute( 'anchorId', anchorId, modelElement );
}
}, { priority: 'high' } );
// Using the same priority that `upcastImageAnchorManualDecorator()` converter guarantees
// that manual decorators will decorate the proper element.
};
}
// Return a converter that adds the `<a>` element to data.
//
// @private
// @params {Object} options
// @params {Boolean} options.attachIconIndicator=false If set to `true`, an icon that informs about the anchored image will be added.
// @returns {Function}
function downcastImageAnchor( options ) {
return dispatcher => {
dispatcher.on( 'attribute:anchorId:image', ( evt, data, conversionApi ) => {
// The image will be already converted - so it will be present in the view.
const viewFigure = conversionApi.mapper.toViewElement( data.item );
const writer = conversionApi.writer;
// But we need to check whether the anchor element exists.
const anchorInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
let anchorIconIndicator;
if ( options.attachIconIndicator ) {
// Create an icon indicator for a anchored image.
anchorIconIndicator = writer.createUIElement( 'span', { class: 'ck ck-anchor-image_icon' }, function( domDocument ) {
const domElement = this.toDomElement( domDocument );
domElement.innerHTML = anchorIcon;
return domElement;
} );
}
// If so, update the attribute if it's defined or remove the entire anchor if the attribute is empty.
if ( anchorInImage ) {
if ( data.attributeNewValue ) {
writer.setAttribute( 'id', data.attributeNewValue, anchorInImage );
} else {
const viewImage = Array.from( anchorInImage.getChildren() ).find( child => child.name === 'img' );
writer.move( writer.createRangeOn( viewImage ), writer.createPositionAt( viewFigure, 0 ) );
writer.remove( anchorInImage );
}
} else {
// But if it does not exist. Let's wrap already converted image by newly created anchor element.
// 1. Create an empty anchor element.
const anchorElement = writer.createContainerElement( 'a', { id: data.attributeNewValue } );
// 2. Insert anchor inside the associated image.
writer.insert( writer.createPositionAt( viewFigure, 0 ), anchorElement );
// 3. Move the image to the anchor.
writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( anchorElement, 0 ) );
// 4. Inset the anchored image icon indicator while downcast to editing.
if ( anchorIconIndicator ) {
writer.insert( writer.createPositionAt( anchorElement, 'end' ), anchorIconIndicator );
}
}
} );
};
}
// Returns a converter that decorates the `<a>` element when the image is the anchor label.
//
// @private
// @returns {Function}
function downcastImageAnchorManualDecorator( manualDecorators, decorator ) {
return dispatcher => {
dispatcher.on( `attribute:${ decorator.id }:image`, ( evt, data, conversionApi ) => {
const attributes = manualDecorators.get( decorator.id ).attributes;
const viewFigure = conversionApi.mapper.toViewElement( data.item );
const anchorInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
for ( const [ key, val ] of toMap( attributes ) ) {
conversionApi.writer.setAttribute( key, val, anchorInImage );
}
} );
};
}
// Returns a converter that checks whether manual decorators should be applied to the anchor.
//
// @private
// @returns {Function}
function upcastImageAnchorManualDecorator( manualDecorators, decorator ) {
return dispatcher => {
dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
const viewAnchor = data.viewItem;
const imageInAnchor = getFirstImage( viewAnchor );
// We need to check whether an image is inside a anchor because the converter handles
// only manual decorators for anchored images. See #7975.
if ( !imageInAnchor ) {
return;
}
const consumableAttributes = {
attributes: manualDecorators.get( decorator.id ).attributes
};
const matcher = new Matcher( consumableAttributes );
const result = matcher.match( viewAnchor );
// The anchor element does not have required attributes or/and proper values.
if ( !result ) {
return;
}
// Check whether we can consume those attributes.
if ( !conversionApi.consumable.consume( viewAnchor, result.match ) ) {
return;
}
// At this stage we can assume that we have the `<image>` element.
// `nodeBefore` comes after conversion: `<a><img></a>`.
// `parent` comes with full image definition: `<figure><a><img></a></figure>.
// See the body of the `upcastAnchor()` function.
const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
conversionApi.writer.setAttribute( decorator.id, true, modelElement );
}, { priority: 'high' } );
// Using the same priority that `upcastAnchor()` converter guarantees that the anchored image was properly converted.
};
}
// Returns the first image in a given view element.
//
// @private
// @param {module:engine/view/element~Element}
// @returns {module:engine/view/element~Element|undefined}
function getFirstImage( viewElement ) {
return Array.from( viewElement.getChildren() ).find( child => child.name === 'img' );
}