imagecaptionengine.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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 ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
  14. import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
  15. import viewWriter from '@ckeditor/ckeditor5-engine/src/view/writer';
  16. import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
  17. import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildviewconverter';
  18. import ViewMatcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
  19. import { isImage, isImageWidget } from '../utils';
  20. import { captionElementCreator, isCaption, getCaptionFromImage } from './utils';
  21. /**
  22. * The image caption engine plugin.
  23. *
  24. * Registers proper converters. Takes care of adding caption element if image without it is inserted to 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 document = editor.document;
  35. const viewDocument = editor.editing.view;
  36. const schema = document.schema;
  37. const data = editor.data;
  38. const editing = editor.editing;
  39. /**
  40. * Last selected caption editable.
  41. * It is used for hiding editable when is empty and image widget is no longer selected.
  42. *
  43. * @member {module:image/imagecaption/imagecaptionengine~ImageCaptionEngine} #_lastSelectedEditable
  44. */
  45. // Schema configuration.
  46. schema.registerItem( 'caption' );
  47. schema.allow( { name: '$inline', inside: 'caption' } );
  48. schema.allow( { name: 'caption', inside: 'image' } );
  49. // Add caption element to each image inserted without it.
  50. document.on( 'change', insertMissingCaptionElement );
  51. // View to model converter for data pipeline.
  52. const matcher = new ViewMatcher( ( element ) => {
  53. const parent = element.parent;
  54. // Convert only captions for images.
  55. if ( element.name == 'figcaption' && parent && parent.name == 'figure' && parent.hasClass( 'image' ) ) {
  56. return { name: true };
  57. }
  58. return null;
  59. } );
  60. buildViewConverter()
  61. .for( data.viewToModel )
  62. .from( matcher )
  63. .toElement( 'caption' );
  64. // Model to view converter for data pipeline.
  65. data.modelToView.on(
  66. 'insert:caption',
  67. captionModelToView( new ViewContainerElement( 'figcaption' ) )
  68. );
  69. // Model to view converter for editing pipeline.
  70. editing.modelToView.on(
  71. 'insert:caption',
  72. captionModelToView( captionElementCreator( viewDocument ) )
  73. );
  74. // Adding / removing caption element when there is no text in the model.
  75. const selection = viewDocument.selection;
  76. // Update view before each rendering.
  77. this.listenTo( viewDocument, 'render', () => {
  78. // Check if there is an empty caption view element to remove.
  79. this._removeEmptyCaption();
  80. // Check if image widget is selected and caption view element needs to be added.
  81. this._addCaption();
  82. // If selection is currently inside caption editable - store it to hide when empty.
  83. const editableElement = selection.editableElement;
  84. if ( editableElement && isCaption( selection.editableElement ) ) {
  85. this._lastSelectedEditable = selection.editableElement;
  86. }
  87. }, { priority: 'high' } );
  88. }
  89. /**
  90. * Checks if there is an empty caption element to remove from view.
  91. *
  92. * @private
  93. */
  94. _removeEmptyCaption() {
  95. const viewSelection = this.editor.editing.view.selection;
  96. const viewCaptionElement = this._lastSelectedEditable;
  97. // No caption to hide.
  98. if ( !viewCaptionElement ) {
  99. return;
  100. }
  101. // If selection is placed inside caption - do not remove it.
  102. if ( viewSelection.editableElement === viewCaptionElement ) {
  103. return;
  104. }
  105. // Do not remove caption if selection is placed on image that contains that caption.
  106. const selectedElement = viewSelection.getSelectedElement();
  107. if ( selectedElement && isImageWidget( selectedElement ) ) {
  108. const viewImage = viewCaptionElement.findAncestor( element => element == selectedElement );
  109. if ( viewImage ) {
  110. return;
  111. }
  112. }
  113. // Remove image caption if its empty.
  114. if ( viewCaptionElement.childCount === 0 ) {
  115. const mapper = this.editor.editing.mapper;
  116. viewWriter.remove( ViewRange.createOn( viewCaptionElement ) );
  117. mapper.unbindViewElement( viewCaptionElement );
  118. }
  119. }
  120. /**
  121. * Checks if selected image needs a new caption element inside.
  122. *
  123. * @private
  124. */
  125. _addCaption() {
  126. const editing = this.editor.editing;
  127. const selection = editing.view.selection;
  128. const imageFigure = selection.getSelectedElement();
  129. const mapper = editing.mapper;
  130. const editableCreator = captionElementCreator( editing.view );
  131. if ( imageFigure && isImageWidget( imageFigure ) ) {
  132. const modelImage = mapper.toModelElement( imageFigure );
  133. const modelCaption = getCaptionFromImage( modelImage );
  134. let viewCaption = mapper.toViewElement( modelCaption );
  135. if ( !viewCaption ) {
  136. viewCaption = editableCreator();
  137. const viewPosition = ViewPosition.createAt( imageFigure, 'end' );
  138. mapper.bindElements( modelCaption, viewCaption );
  139. viewWriter.insert( viewPosition, viewCaption );
  140. }
  141. this._lastSelectedEditable = viewCaption;
  142. }
  143. }
  144. }
  145. // Checks whether data inserted to the model document have image element that has no caption element inside it.
  146. // If there is none - adds it to the image element.
  147. //
  148. // @private
  149. function insertMissingCaptionElement( evt, changeType, data, batch ) {
  150. if ( changeType !== 'insert' ) {
  151. return;
  152. }
  153. const walker = new ModelTreeWalker( {
  154. boundaries: data.range,
  155. ignoreElementEnd: true
  156. } );
  157. for ( let value of walker ) {
  158. const item = value.item;
  159. if ( value.type == 'elementStart' && isImage( item ) && !getCaptionFromImage( item ) ) {
  160. batch.document.enqueueChanges( () => {
  161. batch.insert( ModelPosition.createAt( item, 'end' ), new ModelElement( 'caption' ) );
  162. } );
  163. }
  164. }
  165. }
  166. // Creates a converter that converts image caption model element to view element.
  167. //
  168. // @private
  169. // @param {Function|module:engine/view/element~Element} elementCreator
  170. // @return {Function}
  171. function captionModelToView( elementCreator ) {
  172. return ( evt, data, consumable, conversionApi ) => {
  173. const captionElement = data.item;
  174. if ( isImage( captionElement.parent ) && ( captionElement.childCount > 0 ) ) {
  175. if ( !consumable.consume( data.item, 'insert' ) ) {
  176. return;
  177. }
  178. const imageFigure = conversionApi.mapper.toViewElement( data.range.start.parent );
  179. const viewElement = ( elementCreator instanceof ViewElement ) ?
  180. elementCreator.clone( true ) :
  181. elementCreator( data, consumable, conversionApi );
  182. const viewPosition = ViewPosition.createAt( imageFigure, 'end' );
  183. conversionApi.mapper.bindElements( data.item, viewElement );
  184. viewWriter.insert( viewPosition, viewElement );
  185. }
  186. };
  187. }