8
0

imagecaptionengine.js 8.0 KB

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