imagecaptionengine.js 7.9 KB

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