1d51bcb82d486240307929e1e0a0d7476458dd8f
[yaffs-website] / web / core / modules / ckeditor / js / plugins / drupalimagecaption / plugin.es6.js
1 /**
2  * @file
3  * Drupal Image Caption plugin.
4  *
5  * This alters the existing CKEditor image2 widget plugin, which is already
6  * altered by the Drupal Image plugin, to:
7  * - allow for the data-caption and data-align attributes to be set
8  * - mimic the upcasting behavior of the caption_filter filter.
9  *
10  * @ignore
11  */
12
13 (function (CKEDITOR) {
14   CKEDITOR.plugins.add('drupalimagecaption', {
15     requires: 'drupalimage',
16
17     beforeInit(editor) {
18       // Disable default placeholder text that comes with CKEditor's image2
19       // plugin: it has an inferior UX (it requires the user to manually delete
20       // the place holder text).
21       editor.lang.image2.captionPlaceholder = '';
22
23       // Drupal.t() will not work inside CKEditor plugins because CKEditor loads
24       // the JavaScript file instead of Drupal. Pull translated strings from the
25       // plugin settings that are translated server-side.
26       const placeholderText = editor.config.drupalImageCaption_captionPlaceholderText;
27
28       // Override the image2 widget definition to handle the additional
29       // data-align and data-caption attributes.
30       editor.on('widgetDefinition', (event) => {
31         const widgetDefinition = event.data;
32         if (widgetDefinition.name !== 'image') {
33           return;
34         }
35
36         // Only perform the downcasting/upcasting for to the enabled filters.
37         const captionFilterEnabled = editor.config.drupalImageCaption_captionFilterEnabled;
38         const alignFilterEnabled = editor.config.drupalImageCaption_alignFilterEnabled;
39
40         // Override default features definitions for drupalimagecaption.
41         CKEDITOR.tools.extend(widgetDefinition.features, {
42           caption: {
43             requiredContent: 'img[data-caption]',
44           },
45           align: {
46             requiredContent: 'img[data-align]',
47           },
48         }, true);
49
50         // Extend requiredContent & allowedContent.
51         // CKEDITOR.style is an immutable object: we cannot modify its
52         // definition to extend requiredContent. Hence we get the definition,
53         // modify it, and pass it to a new CKEDITOR.style instance.
54         const requiredContent = widgetDefinition.requiredContent.getDefinition();
55         requiredContent.attributes['data-align'] = '';
56         requiredContent.attributes['data-caption'] = '';
57         widgetDefinition.requiredContent = new CKEDITOR.style(requiredContent);
58         widgetDefinition.allowedContent.img.attributes['!data-align'] = true;
59         widgetDefinition.allowedContent.img.attributes['!data-caption'] = true;
60
61         // Override allowedContent setting for the 'caption' nested editable.
62         // This must match what caption_filter enforces.
63         // @see \Drupal\filter\Plugin\Filter\FilterCaption::process()
64         // @see \Drupal\Component\Utility\Xss::filter()
65         widgetDefinition.editables.caption.allowedContent = 'a[!href]; em strong cite code br';
66
67         // Override downcast(): ensure we *only* output <img>, but also ensure
68         // we include the data-entity-type, data-entity-uuid, data-align and
69         // data-caption attributes.
70         const originalDowncast = widgetDefinition.downcast;
71         widgetDefinition.downcast = function (element) {
72           const img = findElementByName(element, 'img');
73           originalDowncast.call(this, img);
74
75           const caption = this.editables.caption;
76           const captionHtml = caption && caption.getData();
77           const attrs = img.attributes;
78
79           if (captionFilterEnabled) {
80             // If image contains a non-empty caption, serialize caption to the
81             // data-caption attribute.
82             if (captionHtml) {
83               attrs['data-caption'] = captionHtml;
84             }
85           }
86           if (alignFilterEnabled) {
87             if (this.data.align !== 'none') {
88               attrs['data-align'] = this.data.align;
89             }
90           }
91
92           // If img is wrapped with a link, we want to return that link.
93           if (img.parent.name === 'a') {
94             return img.parent;
95           }
96
97           return img;
98         };
99
100         // We want to upcast <img> elements to a DOM structure required by the
101         // image2 widget. Depending on a case it may be:
102         //   - just an <img> tag (non-captioned, not-centered image),
103         //   - <img> tag in a paragraph (non-captioned, centered image),
104         //   - <figure> tag (captioned image).
105         // We take the same attributes into account as downcast() does.
106         const originalUpcast = widgetDefinition.upcast;
107         widgetDefinition.upcast = function (element, data) {
108           if (element.name !== 'img' || !element.attributes['data-entity-type'] || !element.attributes['data-entity-uuid']) {
109             return;
110           }
111           // Don't initialize on pasted fake objects.
112           else if (element.attributes['data-cke-realelement']) {
113             return;
114           }
115
116           element = originalUpcast.call(this, element, data);
117           const attrs = element.attributes;
118
119           if (element.parent.name === 'a') {
120             element = element.parent;
121           }
122
123           let retElement = element;
124           let caption;
125
126           // We won't need the attributes during editing: we'll use widget.data
127           // to store them (except the caption, which is stored in the DOM).
128           if (captionFilterEnabled) {
129             caption = attrs['data-caption'];
130             delete attrs['data-caption'];
131           }
132           if (alignFilterEnabled) {
133             data.align = attrs['data-align'];
134             delete attrs['data-align'];
135           }
136           data['data-entity-type'] = attrs['data-entity-type'];
137           delete attrs['data-entity-type'];
138           data['data-entity-uuid'] = attrs['data-entity-uuid'];
139           delete attrs['data-entity-uuid'];
140
141           if (captionFilterEnabled) {
142             // Unwrap from <p> wrapper created by HTML parser for a captioned
143             // image. The captioned image will be transformed to <figure>, so we
144             // don't want the <p> anymore.
145             if (element.parent.name === 'p' && caption) {
146               let index = element.getIndex();
147               const splitBefore = index > 0;
148               const splitAfter = index + 1 < element.parent.children.length;
149
150               if (splitBefore) {
151                 element.parent.split(index);
152               }
153               index = element.getIndex();
154               if (splitAfter) {
155                 element.parent.split(index + 1);
156               }
157
158               element.parent.replaceWith(element);
159               retElement = element;
160             }
161
162             // If this image has a caption, create a full <figure> structure.
163             if (caption) {
164               const figure = new CKEDITOR.htmlParser.element('figure');
165               caption = new CKEDITOR.htmlParser.fragment.fromHtml(caption, 'figcaption');
166
167               // Use Drupal's data-placeholder attribute to insert a CSS-based,
168               // translation-ready placeholder for empty captions. Note that it
169               // also must to be done for new instances (see
170               // widgetDefinition._createDialogSaveCallback).
171               caption.attributes['data-placeholder'] = placeholderText;
172
173               element.replaceWith(figure);
174               figure.add(element);
175               figure.add(caption);
176               figure.attributes.class = editor.config.image2_captionedClass;
177               retElement = figure;
178             }
179           }
180
181           if (alignFilterEnabled) {
182             // If this image doesn't have a caption (or the caption filter is
183             // disabled), but it is centered, make sure that it's wrapped with
184             // <p>, which will become a part of the widget.
185             if (data.align === 'center' && (!captionFilterEnabled || !caption)) {
186               const p = new CKEDITOR.htmlParser.element('p');
187               element.replaceWith(p);
188               p.add(element);
189               // Apply the class for centered images.
190               p.addClass(editor.config.image2_alignClasses[1]);
191               retElement = p;
192             }
193           }
194
195           // Return the upcasted element (<img>, <figure> or <p>).
196           return retElement;
197         };
198
199         // Protected; keys of the widget data to be sent to the Drupal dialog.
200         // Append to the values defined by the drupalimage plugin.
201         // @see core/modules/ckeditor/js/plugins/drupalimage/plugin.js
202         CKEDITOR.tools.extend(widgetDefinition._mapDataToDialog, {
203           align: 'data-align',
204           'data-caption': 'data-caption',
205           hasCaption: 'hasCaption',
206         });
207
208         // Override Drupal dialog save callback.
209         const originalCreateDialogSaveCallback = widgetDefinition._createDialogSaveCallback;
210         widgetDefinition._createDialogSaveCallback = function (editor, widget) {
211           const saveCallback = originalCreateDialogSaveCallback.call(this, editor, widget);
212
213           return function (dialogReturnValues) {
214             // Ensure hasCaption is a boolean. image2 assumes it always works
215             // with booleans; if this is not the case, then
216             // CKEDITOR.plugins.image2.stateShifter() will incorrectly mark
217             // widget.data.hasCaption as "changed" (e.g. when hasCaption === 0
218             // instead of hasCaption === false). This causes image2's "state
219             // shifter" to enter the wrong branch of the algorithm and blow up.
220             dialogReturnValues.attributes.hasCaption = !!dialogReturnValues.attributes.hasCaption;
221
222             const actualWidget = saveCallback(dialogReturnValues);
223
224             // By default, the template of captioned widget has no
225             // data-placeholder attribute. Note that it also must be done when
226             // upcasting existing elements (see widgetDefinition.upcast).
227             if (dialogReturnValues.attributes.hasCaption) {
228               actualWidget.editables.caption.setAttribute('data-placeholder', placeholderText);
229
230               // Some browsers will add a <br> tag to a newly created DOM
231               // element with no content. Remove this <br> if it is the only
232               // thing in the caption. Our placeholder support requires the
233               // element be entirely empty. See filter-caption.css.
234               const captionElement = actualWidget.editables.caption.$;
235               if (captionElement.childNodes.length === 1 && captionElement.childNodes.item(0).nodeName === 'BR') {
236                 captionElement.removeChild(captionElement.childNodes.item(0));
237               }
238             }
239           };
240         };
241       // Low priority to ensure drupalimage's event handler runs first.
242       }, null, null, 20);
243     },
244
245     afterInit(editor) {
246       const disableButtonIfOnWidget = function (evt) {
247         const widget = editor.widgets.focused;
248         if (widget && widget.name === 'image') {
249           this.setState(CKEDITOR.TRISTATE_DISABLED);
250           evt.cancel();
251         }
252       };
253
254       // Disable alignment buttons if the align filter is not enabled.
255       if (editor.plugins.justify && !editor.config.drupalImageCaption_alignFilterEnabled) {
256         let cmd;
257         const commands = ['justifyleft', 'justifycenter', 'justifyright', 'justifyblock'];
258         for (let n = 0; n < commands.length; n++) {
259           cmd = editor.getCommand(commands[n]);
260           cmd.contextSensitive = 1;
261           cmd.on('refresh', disableButtonIfOnWidget, null, null, 4);
262         }
263       }
264     },
265   });
266
267   /**
268    * Finds an element by its name.
269    *
270    * Function will check first the passed element itself and then all its
271    * children in DFS order.
272    *
273    * @param {CKEDITOR.htmlParser.element} element
274    *   The element to search.
275    * @param {string} name
276    *   The element name to search for.
277    *
278    * @return {?CKEDITOR.htmlParser.element}
279    *   The found element, or null.
280    */
281   function findElementByName(element, name) {
282     if (element.name === name) {
283       return element;
284     }
285
286     let found = null;
287     element.forEach((el) => {
288       if (el.name === name) {
289         found = el;
290         // Stop here.
291         return false;
292       }
293     }, CKEDITOR.NODE_ELEMENT);
294     return found;
295   }
296 }(CKEDITOR));