@ckeditor/ckeditor5-image 27.1.0 → 29.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +3 -3
  3. package/build/image.js +1 -1
  4. package/build/translations/ar.js +1 -0
  5. package/build/translations/ast.js +1 -0
  6. package/build/translations/az.js +1 -0
  7. package/build/translations/bg.js +1 -0
  8. package/build/translations/cs.js +1 -0
  9. package/build/translations/da.js +1 -0
  10. package/build/translations/de-ch.js +1 -0
  11. package/build/translations/de.js +1 -0
  12. package/build/translations/el.js +1 -0
  13. package/build/translations/en-au.js +1 -0
  14. package/build/translations/en-gb.js +1 -0
  15. package/build/translations/eo.js +1 -0
  16. package/build/translations/es.js +1 -0
  17. package/build/translations/et.js +1 -0
  18. package/build/translations/eu.js +1 -0
  19. package/build/translations/fa.js +1 -0
  20. package/build/translations/fi.js +1 -0
  21. package/build/translations/fr.js +1 -0
  22. package/build/translations/gl.js +1 -0
  23. package/build/translations/he.js +1 -0
  24. package/build/translations/hi.js +1 -0
  25. package/build/translations/hr.js +1 -0
  26. package/build/translations/hu.js +1 -0
  27. package/build/translations/id.js +1 -0
  28. package/build/translations/it.js +1 -0
  29. package/build/translations/ja.js +1 -0
  30. package/build/translations/km.js +1 -0
  31. package/build/translations/kn.js +1 -0
  32. package/build/translations/ko.js +1 -0
  33. package/build/translations/ku.js +1 -0
  34. package/build/translations/lt.js +1 -0
  35. package/build/translations/lv.js +1 -0
  36. package/build/translations/nb.js +1 -0
  37. package/build/translations/ne.js +1 -0
  38. package/build/translations/nl.js +1 -0
  39. package/build/translations/no.js +1 -0
  40. package/build/translations/pl.js +1 -0
  41. package/build/translations/pt-br.js +1 -0
  42. package/build/translations/pt.js +1 -0
  43. package/build/translations/ro.js +1 -0
  44. package/build/translations/ru.js +1 -0
  45. package/build/translations/si.js +1 -0
  46. package/build/translations/sk.js +1 -0
  47. package/build/translations/sq.js +1 -0
  48. package/build/translations/sr-latn.js +1 -0
  49. package/build/translations/sr.js +1 -0
  50. package/build/translations/sv.js +1 -0
  51. package/build/translations/th.js +1 -0
  52. package/build/translations/tk.js +1 -0
  53. package/build/translations/tr.js +1 -0
  54. package/build/translations/ug.js +1 -0
  55. package/build/translations/uk.js +1 -0
  56. package/build/translations/vi.js +1 -0
  57. package/build/translations/zh-cn.js +1 -0
  58. package/build/translations/zh.js +1 -0
  59. package/ckeditor5-metadata.json +233 -0
  60. package/lang/contexts.json +3 -0
  61. package/lang/translations/ar.po +12 -0
  62. package/lang/translations/ast.po +12 -0
  63. package/lang/translations/az.po +12 -0
  64. package/lang/translations/bg.po +12 -0
  65. package/lang/translations/cs.po +12 -0
  66. package/lang/translations/da.po +12 -0
  67. package/lang/translations/de-ch.po +113 -0
  68. package/lang/translations/de.po +15 -3
  69. package/lang/translations/el.po +12 -0
  70. package/lang/translations/en-au.po +12 -0
  71. package/lang/translations/en-gb.po +12 -0
  72. package/lang/translations/en.po +12 -0
  73. package/lang/translations/eo.po +12 -0
  74. package/lang/translations/es.po +12 -0
  75. package/lang/translations/et.po +12 -0
  76. package/lang/translations/eu.po +12 -0
  77. package/lang/translations/fa.po +12 -0
  78. package/lang/translations/fi.po +12 -0
  79. package/lang/translations/fr.po +12 -0
  80. package/lang/translations/gl.po +12 -0
  81. package/lang/translations/he.po +12 -0
  82. package/lang/translations/hi.po +12 -0
  83. package/lang/translations/hr.po +12 -0
  84. package/lang/translations/hu.po +13 -1
  85. package/lang/translations/id.po +21 -9
  86. package/lang/translations/it.po +12 -0
  87. package/lang/translations/ja.po +12 -0
  88. package/lang/translations/km.po +12 -0
  89. package/lang/translations/kn.po +12 -0
  90. package/lang/translations/ko.po +12 -0
  91. package/lang/translations/ku.po +12 -0
  92. package/lang/translations/lt.po +12 -0
  93. package/lang/translations/lv.po +12 -0
  94. package/lang/translations/nb.po +12 -0
  95. package/lang/translations/ne.po +12 -0
  96. package/lang/translations/nl.po +12 -0
  97. package/lang/translations/no.po +12 -0
  98. package/lang/translations/pl.po +20 -8
  99. package/lang/translations/pt-br.po +12 -0
  100. package/lang/translations/pt.po +12 -0
  101. package/lang/translations/ro.po +21 -9
  102. package/lang/translations/ru.po +12 -0
  103. package/lang/translations/si.po +12 -0
  104. package/lang/translations/sk.po +12 -0
  105. package/lang/translations/sq.po +12 -0
  106. package/lang/translations/sr-latn.po +12 -0
  107. package/lang/translations/sr.po +12 -0
  108. package/lang/translations/sv.po +12 -0
  109. package/lang/translations/th.po +12 -0
  110. package/lang/translations/tk.po +12 -0
  111. package/lang/translations/tr.po +12 -0
  112. package/lang/translations/ug.po +12 -0
  113. package/lang/translations/uk.po +12 -0
  114. package/lang/translations/vi.po +12 -0
  115. package/lang/translations/zh-cn.po +12 -0
  116. package/lang/translations/zh.po +12 -0
  117. package/package.json +36 -29
  118. package/src/autoimage.js +6 -4
  119. package/src/image/converters.js +192 -13
  120. package/src/image/imageblockediting.js +179 -0
  121. package/src/image/imageediting.js +13 -70
  122. package/src/image/imageinlineediting.js +204 -0
  123. package/src/image/imagetypecommand.js +105 -0
  124. package/src/image/insertimagecommand.js +77 -10
  125. package/src/image/ui/utils.js +3 -3
  126. package/src/image/utils.js +70 -121
  127. package/src/image.js +7 -19
  128. package/src/imageblock.js +46 -0
  129. package/src/imagecaption/imagecaptionediting.js +202 -230
  130. package/src/imagecaption/imagecaptionui.js +78 -0
  131. package/src/imagecaption/toggleimagecaptioncommand.js +165 -0
  132. package/src/imagecaption/utils.js +25 -40
  133. package/src/imagecaption.js +3 -2
  134. package/src/imageinline.js +46 -0
  135. package/src/imageinsert/imageinsertui.js +5 -6
  136. package/src/imageinsert.js +16 -4
  137. package/src/imageresize/imageresizebuttons.js +1 -1
  138. package/src/imageresize/imageresizeediting.js +21 -8
  139. package/src/imageresize/imageresizehandles.js +30 -8
  140. package/src/imageresize/resizeimagecommand.js +8 -5
  141. package/src/imagestyle/converters.js +25 -17
  142. package/src/imagestyle/imagestylecommand.js +73 -33
  143. package/src/imagestyle/imagestyleediting.js +113 -52
  144. package/src/imagestyle/imagestyleui.js +197 -31
  145. package/src/imagestyle/utils.js +300 -85
  146. package/src/imagestyle.js +218 -47
  147. package/src/imagetextalternative/imagetextalternativecommand.js +10 -7
  148. package/src/imagetextalternative/imagetextalternativeediting.js +9 -1
  149. package/src/imagetextalternative/imagetextalternativeui.js +2 -2
  150. package/src/imagetextalternative.js +1 -1
  151. package/src/imagetoolbar.js +33 -11
  152. package/src/imageupload/imageuploadediting.js +91 -31
  153. package/src/imageupload/imageuploadprogress.js +17 -9
  154. package/src/imageupload/imageuploadui.js +1 -1
  155. package/src/imageupload/uploadimagecommand.js +50 -24
  156. package/src/imageupload/utils.js +3 -2
  157. package/src/imageupload.js +1 -1
  158. package/src/imageutils.js +342 -0
  159. package/src/index.js +22 -47
  160. package/src/pictureediting.js +149 -0
  161. package/theme/image.css +101 -21
  162. package/theme/imagecaption.css +24 -2
  163. package/theme/imageresize.css +11 -0
  164. package/theme/imagestyle.css +76 -0
  165. package/theme/imageuploadicon.css +8 -2
  166. package/theme/imageuploadprogress.css +12 -8
  167. package/build/image.js.map +0 -1
@@ -8,18 +8,32 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from 'ckeditor5/src/core';
11
- import { isImage } from '../image/utils';
12
- import { captionElementCreator, getCaptionFromImage, matchImageCaption } from './utils';
11
+ import { Element, enablePlaceholder } from 'ckeditor5/src/engine';
12
+ import { setHighlightHandling, toWidgetEditable } from 'ckeditor5/src/widget';
13
+ import { toArray } from 'ckeditor5/src/utils';
14
+
15
+ import ToggleImageCaptionCommand from './toggleimagecaptioncommand';
16
+
17
+ import ImageUtils from '../imageutils';
18
+ import { getCaptionFromImageModelElement, matchImageCaptionViewElement } from './utils';
13
19
 
14
20
  /**
15
- * The image caption engine plugin.
21
+ * The image caption engine plugin. It is responsible for:
16
22
  *
17
- * It registers proper converters. It takes care of adding a caption element if the image without it is inserted
18
- * to the model document.
23
+ * * registering converters for the caption element,
24
+ * * registering converters for the caption model attribute,
25
+ * * registering the {@link module:image/imagecaption/toggleimagecaptioncommand~ToggleImageCaptionCommand `toggleImageCaption`} command.
19
26
  *
20
27
  * @extends module:core/plugin~Plugin
21
28
  */
22
29
  export default class ImageCaptionEditing extends Plugin {
30
+ /**
31
+ * @inheritDoc
32
+ */
33
+ static get requires() {
34
+ return [ ImageUtils ];
35
+ }
36
+
23
37
  /**
24
38
  * @inheritDoc
25
39
  */
@@ -30,280 +44,238 @@ export default class ImageCaptionEditing extends Plugin {
30
44
  /**
31
45
  * @inheritDoc
32
46
  */
33
- init() {
34
- const editor = this.editor;
35
- const view = editor.editing.view;
36
- const schema = editor.model.schema;
37
- const data = editor.data;
38
- const editing = editor.editing;
39
- const t = editor.t;
47
+ constructor( editor ) {
48
+ super( editor );
40
49
 
41
50
  /**
42
- * The last selected caption editable.
43
- * It is used for hiding the editable when it is empty and the image widget is no longer selected.
51
+ * A map that keeps saved JSONified image captions and image model elements they are
52
+ * associated with.
53
+ *
54
+ * To learn more about this system, see {@link #_saveCaption}.
44
55
  *
45
- * @private
46
- * @member {module:engine/view/editableelement~EditableElement} #_lastSelectedCaption
56
+ * @member {WeakMap.<module:engine/model/element~Element,Object>}
47
57
  */
58
+ this._savedCaptionsMap = new WeakMap();
59
+ }
60
+
61
+ /**
62
+ * @inheritDoc
63
+ */
64
+ init() {
65
+ const editor = this.editor;
66
+ const schema = editor.model.schema;
48
67
 
49
68
  // Schema configuration.
50
- schema.register( 'caption', {
51
- allowIn: 'image',
52
- allowContentOf: '$block',
53
- isLimit: true
54
- } );
69
+ if ( !schema.isRegistered( 'caption' ) ) {
70
+ schema.register( 'caption', {
71
+ allowIn: 'imageBlock',
72
+ allowContentOf: '$block',
73
+ isLimit: true
74
+ } );
75
+ } else {
76
+ schema.extend( 'caption', {
77
+ allowIn: 'imageBlock'
78
+ } );
79
+ }
55
80
 
56
- // Add caption element to each image inserted without it.
57
- editor.model.document.registerPostFixer( writer => this._insertMissingModelCaptionElement( writer ) );
81
+ editor.commands.add( 'toggleImageCaption', new ToggleImageCaptionCommand( this.editor ) );
58
82
 
59
- // View to model converter for the data pipeline.
83
+ this._setupConversion();
84
+ this._setupImageTypeCommandsIntegration();
85
+ }
86
+
87
+ /**
88
+ * Configures conversion pipelines to support upcasting and downcasting
89
+ * image captions.
90
+ *
91
+ * @private
92
+ */
93
+ _setupConversion() {
94
+ const editor = this.editor;
95
+ const view = editor.editing.view;
96
+ const imageUtils = editor.plugins.get( 'ImageUtils' );
97
+ const t = editor.t;
98
+
99
+ // View -> model converter for the data pipeline.
60
100
  editor.conversion.for( 'upcast' ).elementToElement( {
61
- view: matchImageCaption,
101
+ view: element => matchImageCaptionViewElement( imageUtils, element ),
62
102
  model: 'caption'
63
103
  } );
64
104
 
65
- // Model to view converter for the data pipeline.
66
- const createCaptionForData = writer => writer.createContainerElement( 'figcaption' );
67
- data.downcastDispatcher.on( 'insert:caption', captionModelToView( createCaptionForData, false ) );
105
+ // Model -> view converter for the data pipeline.
106
+ editor.conversion.for( 'dataDowncast' ).elementToElement( {
107
+ model: 'caption',
108
+ view: ( modelElement, { writer } ) => {
109
+ if ( !imageUtils.isBlockImage( modelElement.parent ) ) {
110
+ return null;
111
+ }
112
+
113
+ return writer.createContainerElement( 'figcaption' );
114
+ }
115
+ } );
116
+
117
+ // Model -> view converter for the editing pipeline.
118
+ editor.conversion.for( 'editingDowncast' ).elementToElement( {
119
+ model: 'caption',
120
+ view: ( modelElement, { writer } ) => {
121
+ if ( !imageUtils.isBlockImage( modelElement.parent ) ) {
122
+ return null;
123
+ }
124
+
125
+ const figcaptionElement = writer.createEditableElement( 'figcaption' );
126
+ writer.setCustomProperty( 'imageCaption', true, figcaptionElement );
68
127
 
69
- // Model to view converter for the editing pipeline.
70
- const createCaptionForEditing = captionElementCreator( view, t( 'Enter image caption' ) );
71
- editing.downcastDispatcher.on( 'insert:caption', captionModelToView( createCaptionForEditing ) );
128
+ enablePlaceholder( {
129
+ view,
130
+ element: figcaptionElement,
131
+ text: t( 'Enter image caption' ),
132
+ keepOnFocus: true
133
+ } );
72
134
 
73
- // Always show caption in view when something is inserted in model.
74
- editing.downcastDispatcher.on(
75
- 'insert',
76
- this._fixCaptionVisibility( data => data.item ),
77
- { priority: 'high' }
78
- );
135
+ const widgetEditable = toWidgetEditable( figcaptionElement, writer );
79
136
 
80
- // Hide caption when everything is removed from it.
81
- editing.downcastDispatcher.on( 'remove', this._fixCaptionVisibility( data => data.position.parent ), { priority: 'high' } );
137
+ setHighlightHandling(
138
+ widgetEditable,
139
+ writer,
140
+ ( element, descriptor, writer ) => writer.addClass( toArray( descriptor.classes ), element ),
141
+ ( element, descriptor, writer ) => writer.removeClass( toArray( descriptor.classes ), element )
142
+ );
82
143
 
83
- // Update caption visibility on view in post fixer.
84
- view.document.registerPostFixer( writer => this._updateCaptionVisibility( writer ) );
144
+ return widgetEditable;
145
+ }
146
+ } );
147
+
148
+ editor.editing.mapper.on( 'modelToViewPosition', mapModelPositionToView( view ) );
149
+ editor.data.mapper.on( 'modelToViewPosition', mapModelPositionToView( view ) );
85
150
  }
86
151
 
87
152
  /**
88
- * Updates the view before each rendering, making sure that empty captions (so unnecessary ones) are hidden
89
- * and then visible when the image is selected.
153
+ * Integrates with {@link module:image/image/imagetypecommand~ImageTypeCommand image type commands}
154
+ * to make sure the caption is preserved when the type of an image changes so it can be restored
155
+ * in the future if the user decides they want their caption back.
90
156
  *
91
157
  * @private
92
- * @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
93
- * @returns {Boolean} Returns `true` when the view is updated.
94
158
  */
95
- _updateCaptionVisibility( viewWriter ) {
96
- const mapper = this.editor.editing.mapper;
97
- const lastCaption = this._lastSelectedCaption;
98
- let viewCaption;
99
-
100
- // If whole image is selected.
101
- const modelSelection = this.editor.model.document.selection;
102
- const selectedElement = modelSelection.getSelectedElement();
103
-
104
- if ( selectedElement && selectedElement.is( 'element', 'image' ) ) {
105
- const modelCaption = getCaptionFromImage( selectedElement );
106
- viewCaption = mapper.toViewElement( modelCaption );
107
- }
159
+ _setupImageTypeCommandsIntegration() {
160
+ const editor = this.editor;
161
+ const imageUtils = editor.plugins.get( 'ImageUtils' );
162
+ const imageTypeInlineCommand = editor.commands.get( 'imageTypeInline' );
163
+ const imageTypeBlockCommand = editor.commands.get( 'imageTypeBlock' );
108
164
 
109
- // If selection is placed inside caption.
110
- const position = modelSelection.getFirstPosition();
111
- const modelCaption = getParentCaption( position.parent );
165
+ const handleImageTypeChange = evt => {
166
+ // The image type command execution can be unsuccessful.
167
+ if ( !evt.return ) {
168
+ return;
169
+ }
112
170
 
113
- if ( modelCaption ) {
114
- viewCaption = mapper.toViewElement( modelCaption );
115
- }
171
+ const { oldElement, newElement } = evt.return;
116
172
 
117
- // Is currently any caption selected?
118
- if ( viewCaption && !this.editor.isReadOnly ) {
119
- // Was any caption selected before?
120
- if ( lastCaption ) {
121
- // Same caption as before?
122
- if ( lastCaption === viewCaption ) {
123
- return showCaption( viewCaption, viewWriter );
124
- } else {
125
- hideCaptionIfEmpty( lastCaption, viewWriter );
126
- this._lastSelectedCaption = viewCaption;
127
-
128
- return showCaption( viewCaption, viewWriter );
173
+ /* istanbul ignore if: paranoid check */
174
+ if ( !oldElement ) {
175
+ return;
176
+ }
177
+
178
+ if ( imageUtils.isBlockImage( oldElement ) ) {
179
+ const oldCaptionElement = getCaptionFromImageModelElement( oldElement );
180
+
181
+ // If the old element was a captioned block image (the caption was visible),
182
+ // simply save it so it can be restored.
183
+ if ( oldCaptionElement ) {
184
+ this._saveCaption( newElement, oldCaptionElement );
185
+
186
+ return;
129
187
  }
130
- } else {
131
- this._lastSelectedCaption = viewCaption;
132
- return showCaption( viewCaption, viewWriter );
133
188
  }
134
- } else {
135
- // Was any caption selected before?
136
- if ( lastCaption ) {
137
- const viewModified = hideCaptionIfEmpty( lastCaption, viewWriter );
138
- this._lastSelectedCaption = null;
139
-
140
- return viewModified;
141
- } else {
142
- return false;
189
+
190
+ const savedOldElementCaption = this._getSavedCaption( oldElement );
191
+
192
+ // If either:
193
+ //
194
+ // * the block image didn't have a visible caption,
195
+ // * the block image caption was hidden (and already saved),
196
+ // * the inline image was passed
197
+ //
198
+ // just try to "pass" the saved caption from the old image to the new image
199
+ // so it can be retrieved in the future if the user wants it back.
200
+ if ( savedOldElementCaption ) {
201
+ // Note: Since we're writing to a WeakMap, we don't bother with removing the
202
+ // [ oldElement, savedOldElementCaption ] pair from it.
203
+ this._saveCaption( newElement, savedOldElementCaption );
143
204
  }
205
+ };
206
+
207
+ // Presence of the commands depends on the Image(Inline|Block)Editing plugins loaded in the editor.
208
+ if ( imageTypeInlineCommand ) {
209
+ this.listenTo( imageTypeInlineCommand, 'execute', handleImageTypeChange, { priority: 'low' } );
210
+ }
211
+
212
+ if ( imageTypeBlockCommand ) {
213
+ this.listenTo( imageTypeBlockCommand, 'execute', handleImageTypeChange, { priority: 'low' } );
144
214
  }
145
215
  }
146
216
 
147
217
  /**
148
- * Returns a converter that fixes caption visibility during the model-to-view conversion.
149
- * Checks if the changed node is placed inside the caption element and fixes its visibility in the view.
218
+ * Returns the saved {@link module:engine/model/element~Element#toJSON JSONified} caption
219
+ * of an image model element.
150
220
  *
151
- * @private
152
- * @param {Function} nodeFinder
153
- * @returns {Function}
221
+ * See {@link #_saveCaption}.
222
+ *
223
+ * @protected
224
+ * @param {module:engine/model/element~Element} imageModelElement The model element the
225
+ * caption should be returned for.
226
+ * @returns {module:engine/model/element~Element|null} The model caption element or `null` if there is none.
154
227
  */
155
- _fixCaptionVisibility( nodeFinder ) {
156
- return ( evt, data, conversionApi ) => {
157
- const node = nodeFinder( data );
158
- const modelCaption = getParentCaption( node );
159
- const mapper = this.editor.editing.mapper;
160
- const viewWriter = conversionApi.writer;
161
-
162
- if ( modelCaption ) {
163
- const viewCaption = mapper.toViewElement( modelCaption );
164
-
165
- if ( viewCaption ) {
166
- if ( modelCaption.childCount ) {
167
- viewWriter.removeClass( 'ck-hidden', viewCaption );
168
- } else {
169
- viewWriter.addClass( 'ck-hidden', viewCaption );
170
- }
171
- }
172
- }
173
- };
228
+ _getSavedCaption( imageModelElement ) {
229
+ const jsonObject = this._savedCaptionsMap.get( imageModelElement );
230
+
231
+ return jsonObject ? Element.fromJSON( jsonObject ) : null;
174
232
  }
175
233
 
176
234
  /**
177
- * Checks whether the data inserted to the model document have an image element that has no caption element inside it.
178
- * If there is none, it adds it to the image element.
235
+ * Saves a {@link module:engine/model/element~Element#toJSON JSONified} caption for
236
+ * an image element to allow restoring it in the future.
179
237
  *
180
- * @private
181
- * @param {module:engine/model/writer~Writer} writer The writer to make changes with.
182
- * @returns {Boolean} `true` if any change was applied, `false` otherwise.
238
+ * A caption is saved every time it gets hidden and/or the type of an image changes. The
239
+ * user should be able to restore it on demand.
240
+ *
241
+ * **Note**: The caption cannot be stored in the image model element attribute because,
242
+ * for instance, when the model state propagates to collaborators, the attribute would get
243
+ * lost (mainly because it does not convert to anything when the caption is hidden) and
244
+ * the states of collaborators' models would de-synchronize causing numerous issues.
245
+ *
246
+ * See {@link #_getSavedCaption}.
247
+ *
248
+ * @protected
249
+ * @param {module:engine/model/element~Element} imageModelElement The model element the
250
+ * caption is saved for.
251
+ * @param {module:engine/model/element~Element} caption The caption model element to be saved.
183
252
  */
184
- _insertMissingModelCaptionElement( writer ) {
185
- const model = this.editor.model;
186
- const changes = model.document.differ.getChanges();
187
-
188
- const imagesWithoutCaption = [];
189
-
190
- for ( const entry of changes ) {
191
- if ( entry.type == 'insert' && entry.name != '$text' ) {
192
- const item = entry.position.nodeAfter;
193
-
194
- if ( item.is( 'element', 'image' ) && !getCaptionFromImage( item ) ) {
195
- imagesWithoutCaption.push( item );
196
- }
197
-
198
- // Check elements with children for nested images.
199
- if ( !item.is( 'element', 'image' ) && item.childCount ) {
200
- for ( const nestedItem of model.createRangeIn( item ).getItems() ) {
201
- if ( nestedItem.is( 'element', 'image' ) && !getCaptionFromImage( nestedItem ) ) {
202
- imagesWithoutCaption.push( nestedItem );
203
- }
204
- }
205
- }
206
- }
207
- }
208
-
209
- for ( const image of imagesWithoutCaption ) {
210
- writer.appendElement( 'caption', image );
211
- }
212
-
213
- return !!imagesWithoutCaption.length;
253
+ _saveCaption( imageModelElement, caption ) {
254
+ this._savedCaptionsMap.set( imageModelElement, caption.toJSON() );
214
255
  }
215
256
  }
216
257
 
217
- // Creates a converter that converts image caption model element to view element.
258
+ // Creates a mapper callback that reverses the order of `<img>` and `<figcaption>` in the image.
259
+ // Without it, `<figcaption>` would precede the `<img>` in the conversion.
260
+ //
261
+ // <imageBlock>^</imageBlock> -> <figure><img>^<caption></caption></figure>
218
262
  //
219
263
  // @private
220
- // @param {Function} elementCreator
221
- // @param {Boolean} [hide=true] When set to `false` view element will not be inserted when it's empty.
264
+ // @param {module:engine/view/view~View} editingView
222
265
  // @returns {Function}
223
- function captionModelToView( elementCreator, hide = true ) {
224
- return ( evt, data, conversionApi ) => {
225
- const captionElement = data.item;
266
+ function mapModelPositionToView( editingView ) {
267
+ return ( evt, data ) => {
268
+ const modelPosition = data.modelPosition;
269
+ const parent = modelPosition.parent;
226
270
 
227
- // Return if element shouldn't be present when empty.
228
- if ( !captionElement.childCount && !hide ) {
271
+ if ( !parent.is( 'element', 'imageBlock' ) ) {
229
272
  return;
230
273
  }
231
274
 
232
- if ( isImage( captionElement.parent ) ) {
233
- if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
234
- return;
235
- }
236
-
237
- const viewImage = conversionApi.mapper.toViewElement( data.range.start.parent );
238
- const viewCaption = elementCreator( conversionApi.writer );
239
- const viewWriter = conversionApi.writer;
240
-
241
- // Hide if empty.
242
- if ( !captionElement.childCount ) {
243
- viewWriter.addClass( 'ck-hidden', viewCaption );
244
- }
275
+ const viewElement = data.mapper.toViewElement( parent );
245
276
 
246
- insertViewCaptionAndBind( viewCaption, data.item, viewImage, conversionApi );
247
- }
277
+ // The "img" element is inserted by ImageBlockEditing during the downcast conversion via
278
+ // an explicit view position so the "0" position does not need any mapping.
279
+ data.viewPosition = editingView.createPositionAt( viewElement, modelPosition.offset + 1 );
248
280
  };
249
281
  }
250
-
251
- // Inserts `viewCaption` at the end of `viewImage` and binds it to `modelCaption`.
252
- //
253
- // @private
254
- // @param {module:engine/view/containerelement~ContainerElement} viewCaption
255
- // @param {module:engine/model/element~Element} modelCaption
256
- // @param {module:engine/view/containerelement~ContainerElement} viewImage
257
- // @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
258
- function insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, conversionApi ) {
259
- const viewPosition = conversionApi.writer.createPositionAt( viewImage, 'end' );
260
-
261
- conversionApi.writer.insert( viewPosition, viewCaption );
262
- conversionApi.mapper.bindElements( modelCaption, viewCaption );
263
- }
264
-
265
- // Checks if the provided node or one of its ancestors is a caption element, and returns it.
266
- //
267
- // @private
268
- // @param {module:engine/model/node~Node} node
269
- // @returns {module:engine/model/element~Element|null}
270
- function getParentCaption( node ) {
271
- const ancestors = node.getAncestors( { includeSelf: true } );
272
- const caption = ancestors.find( ancestor => ancestor.name == 'caption' );
273
-
274
- if ( caption && caption.parent && caption.parent.name == 'image' ) {
275
- return caption;
276
- }
277
-
278
- return null;
279
- }
280
-
281
- // Hides a given caption in the view if it is empty.
282
- //
283
- // @private
284
- // @param {module:engine/view/containerelement~ContainerElement} caption
285
- // @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
286
- // @returns {Boolean} Returns `true` if the view was modified.
287
- function hideCaptionIfEmpty( caption, viewWriter ) {
288
- if ( !caption.childCount && !caption.hasClass( 'ck-hidden' ) ) {
289
- viewWriter.addClass( 'ck-hidden', caption );
290
- return true;
291
- }
292
-
293
- return false;
294
- }
295
-
296
- // Shows the caption.
297
- //
298
- // @private
299
- // @param {module:engine/view/containerelement~ContainerElement} caption
300
- // @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
301
- // @returns {Boolean} Returns `true` if the view was modified.
302
- function showCaption( caption, viewWriter ) {
303
- if ( caption.hasClass( 'ck-hidden' ) ) {
304
- viewWriter.removeClass( 'ck-hidden', caption );
305
- return true;
306
- }
307
-
308
- return false;
309
- }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+
6
+ /**
7
+ * @module image/imagecaption/imagecaptionui
8
+ */
9
+
10
+ import { Plugin, icons } from 'ckeditor5/src/core';
11
+ import { ButtonView } from 'ckeditor5/src/ui';
12
+ import ImageUtils from '../imageutils';
13
+
14
+ import { getCaptionFromModelSelection } from './utils';
15
+
16
+ /**
17
+ * The image caption UI plugin. It introduces the `'toggleImageCaption'` UI button.
18
+ *
19
+ * @extends module:core/plugin~Plugin
20
+ */
21
+ export default class ImageCaptionUI extends Plugin {
22
+ /**
23
+ * @inheritDoc
24
+ */
25
+ static get requires() {
26
+ return [ ImageUtils ];
27
+ }
28
+
29
+ /**
30
+ * @inheritDoc
31
+ */
32
+ static get pluginName() {
33
+ return 'ImageCaptionUI';
34
+ }
35
+
36
+ /**
37
+ * @inheritDoc
38
+ */
39
+ init() {
40
+ const editor = this.editor;
41
+ const editingView = editor.editing.view;
42
+ const imageUtils = editor.plugins.get( 'ImageUtils' );
43
+ const t = editor.t;
44
+
45
+ editor.ui.componentFactory.add( 'toggleImageCaption', locale => {
46
+ const command = editor.commands.get( 'toggleImageCaption' );
47
+ const view = new ButtonView( locale );
48
+
49
+ view.set( {
50
+ icon: icons.caption,
51
+ tooltip: true,
52
+ isToggleable: true
53
+ } );
54
+
55
+ view.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
56
+ view.bind( 'label' ).to( command, 'value', value => value ? t( 'Toggle caption off' ) : t( 'Toggle caption on' ) );
57
+
58
+ this.listenTo( view, 'execute', () => {
59
+ editor.execute( 'toggleImageCaption', { focusCaptionOnShow: true } );
60
+
61
+ // Scroll to the selection and highlight the caption if the caption showed up.
62
+ const modelCaptionElement = getCaptionFromModelSelection( imageUtils, editor.model.document.selection );
63
+
64
+ if ( modelCaptionElement ) {
65
+ const figcaptionElement = editor.editing.mapper.toViewElement( modelCaptionElement );
66
+
67
+ editingView.scrollToTheSelection();
68
+
69
+ editingView.change( writer => {
70
+ writer.addClass( 'image__caption_highlighted', figcaptionElement );
71
+ } );
72
+ }
73
+ } );
74
+
75
+ return view;
76
+ } );
77
+ }
78
+ }