imagecaptionengine.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module image/imagecaption/imagecaptionengine
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
  10. import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
  11. import viewWriter from '@ckeditor/ckeditor5-engine/src/view/writer';
  12. import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
  13. import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildviewconverter';
  14. import { isImage } from '../image/utils';
  15. import {
  16. captionElementCreator,
  17. getCaptionFromImage,
  18. matchImageCaption
  19. } from './utils';
  20. /**
  21. * The image caption engine plugin.
  22. *
  23. * It registers proper converters. It takes care of adding a caption element if the image without it is inserted
  24. * to the model document.
  25. *
  26. * @extends module:core/plugin~Plugin
  27. */
  28. export default class ImageCaptionEngine extends Plugin {
  29. /**
  30. * @inheritDoc
  31. */
  32. init() {
  33. const editor = this.editor;
  34. const viewDocument = editor.editing.view;
  35. const schema = editor.model.schema;
  36. const data = editor.data;
  37. const editing = editor.editing;
  38. const t = editor.t;
  39. /**
  40. * Last selected caption editable.
  41. * It is used for hiding the editable when it is empty and the image widget is no longer selected.
  42. *
  43. * @private
  44. * @member {module:engine/view/editableelement~EditableElement} #_lastSelectedCaption
  45. */
  46. /**
  47. * A function used to create the editable caption element in the editing view.
  48. *
  49. * @private
  50. * @member {Function}
  51. */
  52. this._createCaption = captionElementCreator( viewDocument, t( 'Enter image caption' ) );
  53. // Schema configuration.
  54. schema.register( 'caption', {
  55. allowIn: 'image',
  56. allowContentOf: '$block',
  57. isLimit: true
  58. } );
  59. // Add caption element to each image inserted without it.
  60. editor.model.document.registerPostFixer( writer => this._insertMissingModelCaptionElement( writer ) );
  61. // View to model converter for the data pipeline.
  62. buildViewConverter()
  63. .for( data.viewToModel )
  64. .from( matchImageCaption )
  65. .toElement( 'caption' );
  66. // Model to view converter for the data pipeline.
  67. data.modelToView.on( 'insert:caption', captionModelToView( new ViewContainerElement( 'figcaption' ), false ) );
  68. // Model to view converter for the editing pipeline.
  69. editing.modelToView.on( 'insert:caption', captionModelToView( this._createCaption ) );
  70. // Always show caption in view when something is inserted in model.
  71. editing.modelToView.on( 'insert', ( evt, data ) => this._fixCaptionVisibility( data.item ), { priority: 'high' } );
  72. // Hide caption when everything is removed from it.
  73. editing.modelToView.on( 'remove', ( evt, data ) => this._fixCaptionVisibility( data.position.parent ), { priority: 'high' } );
  74. // Update view before each rendering.
  75. this.listenTo( viewDocument, 'render', () => this._updateCaptionVisibility(), { priority: 'high' } );
  76. }
  77. /**
  78. * Updates the view before each rendering, making sure that empty captions (so unnecessary ones) are hidden
  79. * and then visible when the image is selected.
  80. *
  81. * @private
  82. */
  83. _updateCaptionVisibility() {
  84. const mapper = this.editor.editing.mapper;
  85. let viewCaption;
  86. // Hide last selected caption if have no child elements.
  87. if ( this._lastSelectedCaption && !this._lastSelectedCaption.childCount ) {
  88. this._lastSelectedCaption.addClass( 'ck-hidden' );
  89. }
  90. // If whole image is selected.
  91. const modelSelection = this.editor.model.document.selection;
  92. const selectedElement = modelSelection.getSelectedElement();
  93. if ( selectedElement && selectedElement.is( 'image' ) ) {
  94. const modelCaption = getCaptionFromImage( selectedElement );
  95. viewCaption = mapper.toViewElement( modelCaption );
  96. }
  97. // If selection is placed inside caption.
  98. const position = modelSelection.getFirstPosition();
  99. const modelCaption = getParentCaption( position.parent );
  100. if ( modelCaption ) {
  101. viewCaption = mapper.toViewElement( modelCaption );
  102. }
  103. if ( viewCaption ) {
  104. viewCaption.removeClass( 'ck-hidden' );
  105. this._lastSelectedCaption = viewCaption;
  106. }
  107. }
  108. /**
  109. * Fixes caption visibility during the model-to-view conversion.
  110. * Checks if the changed node is placed inside the caption element and fixes its visibility in the view.
  111. *
  112. * @private
  113. * @param {module:engine/model/node~Node} node
  114. */
  115. _fixCaptionVisibility( node ) {
  116. const modelCaption = getParentCaption( node );
  117. const mapper = this.editor.editing.mapper;
  118. if ( modelCaption ) {
  119. const viewCaption = mapper.toViewElement( modelCaption );
  120. if ( viewCaption ) {
  121. if ( modelCaption.childCount ) {
  122. viewCaption.removeClass( 'ck-hidden' );
  123. } else {
  124. viewCaption.addClass( 'ck-hidden' );
  125. }
  126. }
  127. }
  128. }
  129. /**
  130. * Checks whether data inserted to the model document have image element that has no caption element inside it.
  131. * If there is none - adds it to the image element.
  132. *
  133. * @private
  134. * @param {module:engine/model/writer~Writer} writer Writer to make changes with.
  135. * @returns {Boolean} `true` if any change has been applied, `false` otherwise.
  136. */
  137. _insertMissingModelCaptionElement( writer ) {
  138. const model = this.editor.model;
  139. const changes = model.document.differ.getChanges();
  140. for ( const entry of changes ) {
  141. if ( entry.type == 'insert' && entry.name == 'image' ) {
  142. const item = entry.position.nodeAfter;
  143. if ( !getCaptionFromImage( item ) ) {
  144. writer.appendElement( 'caption', item );
  145. return true;
  146. }
  147. }
  148. }
  149. }
  150. }
  151. // Creates a converter that converts image caption model element to view element.
  152. //
  153. // @private
  154. // @param {Function|module:engine/view/element~Element} elementCreator
  155. // @param {Boolean} [hide=true] When set to `false` view element will not be inserted when it's empty.
  156. // @return {Function}
  157. function captionModelToView( elementCreator, hide = true ) {
  158. return ( evt, data, consumable, conversionApi ) => {
  159. const captionElement = data.item;
  160. // Return if element shouldn't be present when empty.
  161. if ( !captionElement.childCount && !hide ) {
  162. return;
  163. }
  164. if ( isImage( captionElement.parent ) ) {
  165. if ( !consumable.consume( data.item, 'insert' ) ) {
  166. return;
  167. }
  168. const viewImage = conversionApi.mapper.toViewElement( data.range.start.parent );
  169. const viewCaption = ( elementCreator instanceof ViewElement ) ?
  170. elementCreator.clone( true ) :
  171. elementCreator();
  172. // Hide if empty.
  173. if ( !captionElement.childCount ) {
  174. viewCaption.addClass( 'ck-hidden' );
  175. }
  176. insertViewCaptionAndBind( viewCaption, data.item, viewImage, conversionApi.mapper );
  177. }
  178. };
  179. }
  180. // Inserts `viewCaption` at the end of `viewImage` and binds it to `modelCaption`.
  181. //
  182. // @private
  183. // @param {module:engine/view/containerelement~ContainerElement} viewCaption
  184. // @param {module:engine/model/element~Element} modelCaption
  185. // @param {module:engine/view/containerelement~ContainerElement} viewImage
  186. // @param {module:engine/conversion/mapper~Mapper} mapper
  187. function insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, mapper ) {
  188. const viewPosition = ViewPosition.createAt( viewImage, 'end' );
  189. viewWriter.insert( viewPosition, viewCaption );
  190. mapper.bindElements( modelCaption, viewCaption );
  191. }
  192. // Checks if the provided node or one of its ancestors is a caption element, and returns it.
  193. //
  194. // @private
  195. // @param {module:engine/model/node~Node} node
  196. // @returns {module:engine/model/element~Element|null}
  197. function getParentCaption( node ) {
  198. const ancestors = node.getAncestors( { includeSelf: true } );
  199. const caption = ancestors.find( ancestor => ancestor.name == 'caption' );
  200. if ( caption && caption.parent && caption.parent.name == 'image' ) {
  201. return caption;
  202. }
  203. return null;
  204. }