imagecaptionediting.js 9.0 KB

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