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 | */
|
8 | import { Plugin } from 'ckeditor5/src/core.js';
|
9 | import { Element, enablePlaceholder } from 'ckeditor5/src/engine.js';
|
10 | import { toWidgetEditable } from 'ckeditor5/src/widget.js';
|
11 | import ToggleImageCaptionCommand from './toggleimagecaptioncommand.js';
|
12 | import ImageUtils from '../imageutils.js';
|
13 | import 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 | */
|
21 | export 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 | }
|