ckeditor5-image-upload-base64
Version:
The development environment of CKEditor 5 – the best browser-based rich text editor.
310 lines (263 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 image/imagecaption/imagecaptionediting
*/
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { isImage } from '../image/utils';
import { captionElementCreator, getCaptionFromImage, matchImageCaption } from './utils';
/**
* The image caption engine plugin.
*
* It registers proper converters. It takes care of adding a caption element if the image without it is inserted
* to the model document.
*
* @extends module:core/plugin~Plugin
*/
export default class ImageCaptionEditing extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'ImageCaptionEditing';
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const view = editor.editing.view;
const schema = editor.model.schema;
const data = editor.data;
const editing = editor.editing;
const t = editor.t;
/**
* The last selected caption editable.
* It is used for hiding the editable when it is empty and the image widget is no longer selected.
*
* @private
* @member {module:engine/view/editableelement~EditableElement} #_lastSelectedCaption
*/
// Schema configuration.
schema.register( 'caption', {
allowIn: 'image',
allowContentOf: '$block',
isLimit: true
} );
// Add caption element to each image inserted without it.
editor.model.document.registerPostFixer( writer => this._insertMissingModelCaptionElement( writer ) );
// View to model converter for the data pipeline.
editor.conversion.for( 'upcast' ).elementToElement( {
view: matchImageCaption,
model: 'caption'
} );
// Model to view converter for the data pipeline.
const createCaptionForData = writer => writer.createContainerElement( 'figcaption' );
data.downcastDispatcher.on( 'insert:caption', captionModelToView( createCaptionForData, false ) );
// Model to view converter for the editing pipeline.
const createCaptionForEditing = captionElementCreator( view, t( 'Enter image caption' ) );
editing.downcastDispatcher.on( 'insert:caption', captionModelToView( createCaptionForEditing ) );
// Always show caption in view when something is inserted in model.
editing.downcastDispatcher.on(
'insert',
this._fixCaptionVisibility( data => data.item ),
{ priority: 'high' }
);
// Hide caption when everything is removed from it.
editing.downcastDispatcher.on( 'remove', this._fixCaptionVisibility( data => data.position.parent ), { priority: 'high' } );
// Update caption visibility on view in post fixer.
view.document.registerPostFixer( writer => this._updateCaptionVisibility( writer ) );
}
/**
* Updates the view before each rendering, making sure that empty captions (so unnecessary ones) are hidden
* and then visible when the image is selected.
*
* @private
* @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
* @returns {Boolean} Returns `true` when the view is updated.
*/
_updateCaptionVisibility( viewWriter ) {
const mapper = this.editor.editing.mapper;
const lastCaption = this._lastSelectedCaption;
let viewCaption;
// If whole image is selected.
const modelSelection = this.editor.model.document.selection;
const selectedElement = modelSelection.getSelectedElement();
if ( selectedElement && selectedElement.is( 'element', 'image' ) ) {
const modelCaption = getCaptionFromImage( selectedElement );
viewCaption = mapper.toViewElement( modelCaption );
}
// If selection is placed inside caption.
const position = modelSelection.getFirstPosition();
const modelCaption = getParentCaption( position.parent );
if ( modelCaption ) {
viewCaption = mapper.toViewElement( modelCaption );
}
// Is currently any caption selected?
if ( viewCaption ) {
// Was any caption selected before?
if ( lastCaption ) {
// Same caption as before?
if ( lastCaption === viewCaption ) {
return showCaption( viewCaption, viewWriter );
} else {
hideCaptionIfEmpty( lastCaption, viewWriter );
this._lastSelectedCaption = viewCaption;
return showCaption( viewCaption, viewWriter );
}
} else {
this._lastSelectedCaption = viewCaption;
return showCaption( viewCaption, viewWriter );
}
} else {
// Was any caption selected before?
if ( lastCaption ) {
const viewModified = hideCaptionIfEmpty( lastCaption, viewWriter );
this._lastSelectedCaption = null;
return viewModified;
} else {
return false;
}
}
}
/**
* Returns a converter that fixes caption visibility during the model-to-view conversion.
* Checks if the changed node is placed inside the caption element and fixes its visibility in the view.
*
* @private
* @param {Function} nodeFinder
* @returns {Function}
*/
_fixCaptionVisibility( nodeFinder ) {
return ( evt, data, conversionApi ) => {
const node = nodeFinder( data );
const modelCaption = getParentCaption( node );
const mapper = this.editor.editing.mapper;
const viewWriter = conversionApi.writer;
if ( modelCaption ) {
const viewCaption = mapper.toViewElement( modelCaption );
if ( viewCaption ) {
if ( modelCaption.childCount ) {
viewWriter.removeClass( 'ck-hidden', viewCaption );
} else {
viewWriter.addClass( 'ck-hidden', viewCaption );
}
}
}
};
}
/**
* Checks whether the data inserted to the model document have an image element that has no caption element inside it.
* If there is none, it adds it to the image element.
*
* @private
* @param {module:engine/model/writer~Writer} writer The writer to make changes with.
* @returns {Boolean} `true` if any change was applied, `false` otherwise.
*/
_insertMissingModelCaptionElement( writer ) {
const model = this.editor.model;
const changes = model.document.differ.getChanges();
const imagesWithoutCaption = [];
for ( const entry of changes ) {
if ( entry.type == 'insert' && entry.name != '$text' ) {
const item = entry.position.nodeAfter;
if ( item.is( 'element', 'image' ) && !getCaptionFromImage( item ) ) {
imagesWithoutCaption.push( item );
}
// Check elements with children for nested images.
if ( !item.is( 'element', 'image' ) && item.childCount ) {
for ( const nestedItem of model.createRangeIn( item ).getItems() ) {
if ( nestedItem.is( 'element', 'image' ) && !getCaptionFromImage( nestedItem ) ) {
imagesWithoutCaption.push( nestedItem );
}
}
}
}
}
for ( const image of imagesWithoutCaption ) {
writer.appendElement( 'caption', image );
}
return !!imagesWithoutCaption.length;
}
}
// Creates a converter that converts image caption model element to view element.
//
// @private
// @param {Function} elementCreator
// @param {Boolean} [hide=true] When set to `false` view element will not be inserted when it's empty.
// @returns {Function}
function captionModelToView( elementCreator, hide = true ) {
return ( evt, data, conversionApi ) => {
const captionElement = data.item;
// Return if element shouldn't be present when empty.
if ( !captionElement.childCount && !hide ) {
return;
}
if ( isImage( captionElement.parent ) ) {
if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
return;
}
const viewImage = conversionApi.mapper.toViewElement( data.range.start.parent );
const viewCaption = elementCreator( conversionApi.writer );
const viewWriter = conversionApi.writer;
// Hide if empty.
if ( !captionElement.childCount ) {
viewWriter.addClass( 'ck-hidden', viewCaption );
}
insertViewCaptionAndBind( viewCaption, data.item, viewImage, conversionApi );
}
};
}
// Inserts `viewCaption` at the end of `viewImage` and binds it to `modelCaption`.
//
// @private
// @param {module:engine/view/containerelement~ContainerElement} viewCaption
// @param {module:engine/model/element~Element} modelCaption
// @param {module:engine/view/containerelement~ContainerElement} viewImage
// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
function insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, conversionApi ) {
const viewPosition = conversionApi.writer.createPositionAt( viewImage, 'end' );
conversionApi.writer.insert( viewPosition, viewCaption );
conversionApi.mapper.bindElements( modelCaption, viewCaption );
}
// Checks if the provided node or one of its ancestors is a caption element, and returns it.
//
// @private
// @param {module:engine/model/node~Node} node
// @returns {module:engine/model/element~Element|null}
function getParentCaption( node ) {
const ancestors = node.getAncestors( { includeSelf: true } );
const caption = ancestors.find( ancestor => ancestor.name == 'caption' );
if ( caption && caption.parent && caption.parent.name == 'image' ) {
return caption;
}
return null;
}
// Hides a given caption in the view if it is empty.
//
// @private
// @param {module:engine/view/containerelement~ContainerElement} caption
// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
// @returns {Boolean} Returns `true` if the view was modified.
function hideCaptionIfEmpty( caption, viewWriter ) {
if ( !caption.childCount && !caption.hasClass( 'ck-hidden' ) ) {
viewWriter.addClass( 'ck-hidden', caption );
return true;
}
return false;
}
// Shows the caption.
//
// @private
// @param {module:engine/view/containerelement~ContainerElement} caption
// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
// @returns {Boolean} Returns `true` if the view was modified.
function showCaption( caption, viewWriter ) {
if ( caption.hasClass( 'ck-hidden' ) ) {
viewWriter.removeClass( 'ck-hidden', caption );
return true;
}
return false;
}