UNPKG

7.63 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5
6/**
7 * @module image/imagecaption/imagecaptionediting
8 */
9
10import { Plugin } from 'ckeditor5/src/core';
11import { Element, enablePlaceholder } from 'ckeditor5/src/engine';
12import { toWidgetEditable } from 'ckeditor5/src/widget';
13
14import ToggleImageCaptionCommand from './toggleimagecaptioncommand';
15
16import ImageUtils from '../imageutils';
17import ImageCaptionUtils from './imagecaptionutils';
18
19/**
20 * The image caption engine plugin. It is responsible for:
21 *
22 * * registering converters for the caption element,
23 * * registering converters for the caption model attribute,
24 * * registering the {@link module:image/imagecaption/toggleimagecaptioncommand~ToggleImageCaptionCommand `toggleImageCaption`} command.
25 *
26 * @extends module:core/plugin~Plugin
27 */
28export default class ImageCaptionEditing extends Plugin {
29 /**
30 * @inheritDoc
31 */
32 static get requires() {
33 return [ ImageUtils, ImageCaptionUtils ];
34 }
35
36 /**
37 * @inheritDoc
38 */
39 static get pluginName() {
40 return 'ImageCaptionEditing';
41 }
42
43 /**
44 * @inheritDoc
45 */
46 constructor( editor ) {
47 super( editor );
48
49 /**
50 * A map that keeps saved JSONified image captions and image model elements they are
51 * associated with.
52 *
53 * To learn more about this system, see {@link #_saveCaption}.
54 *
55 * @member {WeakMap.<module:engine/model/element~Element,Object>}
56 */
57 this._savedCaptionsMap = new WeakMap();
58 }
59
60 /**
61 * @inheritDoc
62 */
63 init() {
64 const editor = this.editor;
65 const schema = editor.model.schema;
66
67 // Schema configuration.
68 if ( !schema.isRegistered( 'caption' ) ) {
69 schema.register( 'caption', {
70 allowIn: 'imageBlock',
71 allowContentOf: '$block',
72 isLimit: true
73 } );
74 } else {
75 schema.extend( 'caption', {
76 allowIn: 'imageBlock'
77 } );
78 }
79
80 editor.commands.add( 'toggleImageCaption', new ToggleImageCaptionCommand( this.editor ) );
81
82 this._setupConversion();
83 this._setupImageTypeCommandsIntegration();
84 }
85
86 /**
87 * Configures conversion pipelines to support upcasting and downcasting
88 * image captions.
89 *
90 * @private
91 */
92 _setupConversion() {
93 const editor = this.editor;
94 const view = editor.editing.view;
95 const imageUtils = editor.plugins.get( 'ImageUtils' );
96 const imageCaptionUtils = editor.plugins.get( 'ImageCaptionUtils' );
97 const t = editor.t;
98
99 // View -> model converter for the data pipeline.
100 editor.conversion.for( 'upcast' ).elementToElement( {
101 view: element => imageCaptionUtils.matchImageCaptionViewElement( element ),
102 model: 'caption'
103 } );
104
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 );
127
128 enablePlaceholder( {
129 view,
130 element: figcaptionElement,
131 text: t( 'Enter image caption' ),
132 keepOnFocus: true
133 } );
134
135 return toWidgetEditable( figcaptionElement, writer );
136 }
137 } );
138 }
139
140 /**
141 * Integrates with {@link module:image/image/imagetypecommand~ImageTypeCommand image type commands}
142 * to make sure the caption is preserved when the type of an image changes so it can be restored
143 * in the future if the user decides they want their caption back.
144 *
145 * @private
146 */
147 _setupImageTypeCommandsIntegration() {
148 const editor = this.editor;
149 const imageUtils = editor.plugins.get( 'ImageUtils' );
150 const imageCaptionUtils = editor.plugins.get( 'ImageCaptionUtils' );
151 const imageTypeInlineCommand = editor.commands.get( 'imageTypeInline' );
152 const imageTypeBlockCommand = editor.commands.get( 'imageTypeBlock' );
153
154 const handleImageTypeChange = evt => {
155 // The image type command execution can be unsuccessful.
156 if ( !evt.return ) {
157 return;
158 }
159
160 const { oldElement, newElement } = evt.return;
161
162 /* istanbul ignore if: paranoid check */
163 if ( !oldElement ) {
164 return;
165 }
166
167 if ( imageUtils.isBlockImage( oldElement ) ) {
168 const oldCaptionElement = imageCaptionUtils.getCaptionFromImageModelElement( oldElement );
169
170 // If the old element was a captioned block image (the caption was visible),
171 // simply save it so it can be restored.
172 if ( oldCaptionElement ) {
173 this._saveCaption( newElement, oldCaptionElement );
174
175 return;
176 }
177 }
178
179 const savedOldElementCaption = this._getSavedCaption( oldElement );
180
181 // If either:
182 //
183 // * the block image didn't have a visible caption,
184 // * the block image caption was hidden (and already saved),
185 // * the inline image was passed
186 //
187 // just try to "pass" the saved caption from the old image to the new image
188 // so it can be retrieved in the future if the user wants it back.
189 if ( savedOldElementCaption ) {
190 // Note: Since we're writing to a WeakMap, we don't bother with removing the
191 // [ oldElement, savedOldElementCaption ] pair from it.
192 this._saveCaption( newElement, savedOldElementCaption );
193 }
194 };
195
196 // Presence of the commands depends on the Image(Inline|Block)Editing plugins loaded in the editor.
197 if ( imageTypeInlineCommand ) {
198 this.listenTo( imageTypeInlineCommand, 'execute', handleImageTypeChange, { priority: 'low' } );
199 }
200
201 if ( imageTypeBlockCommand ) {
202 this.listenTo( imageTypeBlockCommand, 'execute', handleImageTypeChange, { priority: 'low' } );
203 }
204 }
205
206 /**
207 * Returns the saved {@link module:engine/model/element~Element#toJSON JSONified} caption
208 * of an image model element.
209 *
210 * See {@link #_saveCaption}.
211 *
212 * @protected
213 * @param {module:engine/model/element~Element} imageModelElement The model element the
214 * caption should be returned for.
215 * @returns {module:engine/model/element~Element|null} The model caption element or `null` if there is none.
216 */
217 _getSavedCaption( imageModelElement ) {
218 const jsonObject = this._savedCaptionsMap.get( imageModelElement );
219
220 return jsonObject ? Element.fromJSON( jsonObject ) : null;
221 }
222
223 /**
224 * Saves a {@link module:engine/model/element~Element#toJSON JSONified} caption for
225 * an image element to allow restoring it in the future.
226 *
227 * A caption is saved every time it gets hidden and/or the type of an image changes. The
228 * user should be able to restore it on demand.
229 *
230 * **Note**: The caption cannot be stored in the image model element attribute because,
231 * for instance, when the model state propagates to collaborators, the attribute would get
232 * lost (mainly because it does not convert to anything when the caption is hidden) and
233 * the states of collaborators' models would de-synchronize causing numerous issues.
234 *
235 * See {@link #_getSavedCaption}.
236 *
237 * @protected
238 * @param {module:engine/model/element~Element} imageModelElement The model element the
239 * caption is saved for.
240 * @param {module:engine/model/element~Element} caption The caption model element to be saved.
241 */
242 _saveCaption( imageModelElement, caption ) {
243 this._savedCaptionsMap.set( imageModelElement, caption.toJSON() );
244 }
245}