UNPKG

9.52 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2024, 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 * @module image/imagecaption/imagecaptionediting
7 */
8import { Plugin } from 'ckeditor5/src/core.js';
9import { Element, enablePlaceholder } from 'ckeditor5/src/engine.js';
10import { toWidgetEditable } from 'ckeditor5/src/widget.js';
11import ToggleImageCaptionCommand from './toggleimagecaptioncommand.js';
12import ImageUtils from '../imageutils.js';
13import ImageCaptionUtils from './imagecaptionutils.js';
14/**
15 * The image caption engine plugin. It is responsible for:
16 *
17 * * registering converters for the caption element,
18 * * registering converters for the caption model attribute,
19 * * registering the {@link module:image/imagecaption/toggleimagecaptioncommand~ToggleImageCaptionCommand `toggleImageCaption`} command.
20 */
21export default class ImageCaptionEditing extends Plugin {
22 /**
23 * @inheritDoc
24 */
25 static get requires() {
26 return [ImageUtils, ImageCaptionUtils];
27 }
28 /**
29 * @inheritDoc
30 */
31 static get pluginName() {
32 return 'ImageCaptionEditing';
33 }
34 /**
35 * @inheritDoc
36 */
37 constructor(editor) {
38 super(editor);
39 this._savedCaptionsMap = new WeakMap();
40 }
41 /**
42 * @inheritDoc
43 */
44 init() {
45 const editor = this.editor;
46 const schema = editor.model.schema;
47 // Schema configuration.
48 if (!schema.isRegistered('caption')) {
49 schema.register('caption', {
50 allowIn: 'imageBlock',
51 allowContentOf: '$block',
52 isLimit: true
53 });
54 }
55 else {
56 schema.extend('caption', {
57 allowIn: 'imageBlock'
58 });
59 }
60 editor.commands.add('toggleImageCaption', new ToggleImageCaptionCommand(this.editor));
61 this._setupConversion();
62 this._setupImageTypeCommandsIntegration();
63 this._registerCaptionReconversion();
64 }
65 /**
66 * Configures conversion pipelines to support upcasting and downcasting
67 * image captions.
68 */
69 _setupConversion() {
70 const editor = this.editor;
71 const view = editor.editing.view;
72 const imageUtils = editor.plugins.get('ImageUtils');
73 const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
74 const t = editor.t;
75 // View -> model converter for the data pipeline.
76 editor.conversion.for('upcast').elementToElement({
77 view: element => imageCaptionUtils.matchImageCaptionViewElement(element),
78 model: 'caption'
79 });
80 // Model -> view converter for the data pipeline.
81 editor.conversion.for('dataDowncast').elementToElement({
82 model: 'caption',
83 view: (modelElement, { writer }) => {
84 if (!imageUtils.isBlockImage(modelElement.parent)) {
85 return null;
86 }
87 return writer.createContainerElement('figcaption');
88 }
89 });
90 // Model -> view converter for the editing pipeline.
91 editor.conversion.for('editingDowncast').elementToElement({
92 model: 'caption',
93 view: (modelElement, { writer }) => {
94 if (!imageUtils.isBlockImage(modelElement.parent)) {
95 return null;
96 }
97 const figcaptionElement = writer.createEditableElement('figcaption');
98 writer.setCustomProperty('imageCaption', true, figcaptionElement);
99 figcaptionElement.placeholder = t('Enter image caption');
100 enablePlaceholder({
101 view,
102 element: figcaptionElement,
103 keepOnFocus: true
104 });
105 const imageAlt = modelElement.parent.getAttribute('alt');
106 const label = imageAlt ? t('Caption for image: %0', [imageAlt]) : t('Caption for the image');
107 return toWidgetEditable(figcaptionElement, writer, { label });
108 }
109 });
110 }
111 /**
112 * Integrates with {@link module:image/image/imagetypecommand~ImageTypeCommand image type commands}
113 * to make sure the caption is preserved when the type of an image changes so it can be restored
114 * in the future if the user decides they want their caption back.
115 */
116 _setupImageTypeCommandsIntegration() {
117 const editor = this.editor;
118 const imageUtils = editor.plugins.get('ImageUtils');
119 const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
120 const imageTypeInlineCommand = editor.commands.get('imageTypeInline');
121 const imageTypeBlockCommand = editor.commands.get('imageTypeBlock');
122 const handleImageTypeChange = evt => {
123 // The image type command execution can be unsuccessful.
124 if (!evt.return) {
125 return;
126 }
127 const { oldElement, newElement } = evt.return;
128 /* istanbul ignore if: paranoid check -- @preserve */
129 if (!oldElement) {
130 return;
131 }
132 if (imageUtils.isBlockImage(oldElement)) {
133 const oldCaptionElement = imageCaptionUtils.getCaptionFromImageModelElement(oldElement);
134 // If the old element was a captioned block image (the caption was visible),
135 // simply save it so it can be restored.
136 if (oldCaptionElement) {
137 this._saveCaption(newElement, oldCaptionElement);
138 return;
139 }
140 }
141 const savedOldElementCaption = this._getSavedCaption(oldElement);
142 // If either:
143 //
144 // * the block image didn't have a visible caption,
145 // * the block image caption was hidden (and already saved),
146 // * the inline image was passed
147 //
148 // just try to "pass" the saved caption from the old image to the new image
149 // so it can be retrieved in the future if the user wants it back.
150 if (savedOldElementCaption) {
151 // Note: Since we're writing to a WeakMap, we don't bother with removing the
152 // [ oldElement, savedOldElementCaption ] pair from it.
153 this._saveCaption(newElement, savedOldElementCaption);
154 }
155 };
156 // Presence of the commands depends on the Image(Inline|Block)Editing plugins loaded in the editor.
157 if (imageTypeInlineCommand) {
158 this.listenTo(imageTypeInlineCommand, 'execute', handleImageTypeChange, { priority: 'low' });
159 }
160 if (imageTypeBlockCommand) {
161 this.listenTo(imageTypeBlockCommand, 'execute', handleImageTypeChange, { priority: 'low' });
162 }
163 }
164 /**
165 * Returns the saved {@link module:engine/model/element~Element#toJSON JSONified} caption
166 * of an image model element.
167 *
168 * See {@link #_saveCaption}.
169 *
170 * @internal
171 * @param imageModelElement The model element the caption should be returned for.
172 * @returns The model caption element or `null` if there is none.
173 */
174 _getSavedCaption(imageModelElement) {
175 const jsonObject = this._savedCaptionsMap.get(imageModelElement);
176 return jsonObject ? Element.fromJSON(jsonObject) : null;
177 }
178 /**
179 * Saves a {@link module:engine/model/element~Element#toJSON JSONified} caption for
180 * an image element to allow restoring it in the future.
181 *
182 * A caption is saved every time it gets hidden and/or the type of an image changes. The
183 * user should be able to restore it on demand.
184 *
185 * **Note**: The caption cannot be stored in the image model element attribute because,
186 * for instance, when the model state propagates to collaborators, the attribute would get
187 * lost (mainly because it does not convert to anything when the caption is hidden) and
188 * the states of collaborators' models would de-synchronize causing numerous issues.
189 *
190 * See {@link #_getSavedCaption}.
191 *
192 * @internal
193 * @param imageModelElement The model element the caption is saved for.
194 * @param caption The caption model element to be saved.
195 */
196 _saveCaption(imageModelElement, caption) {
197 this._savedCaptionsMap.set(imageModelElement, caption.toJSON());
198 }
199 /**
200 * Reconverts image caption when image alt attribute changes.
201 * The change of alt attribute is reflected in caption's aria-label attribute.
202 */
203 _registerCaptionReconversion() {
204 const editor = this.editor;
205 const model = editor.model;
206 const imageUtils = editor.plugins.get('ImageUtils');
207 const imageCaptionUtils = editor.plugins.get('ImageCaptionUtils');
208 model.document.on('change:data', () => {
209 const changes = model.document.differ.getChanges();
210 for (const change of changes) {
211 if (change.attributeKey !== 'alt') {
212 continue;
213 }
214 const image = change.range.start.nodeAfter;
215 if (imageUtils.isBlockImage(image)) {
216 const caption = imageCaptionUtils.getCaptionFromImageModelElement(image);
217 if (!caption) {
218 return;
219 }
220 editor.editing.reconvertItem(caption);
221 }
222 }
223 });
224 }
225}