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