3 * Drupal Image Caption plugin.
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.
13 (function (CKEDITOR) {
17 CKEDITOR.plugins.add('drupalimagecaption', {
18 requires: 'drupalimage',
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 = '';
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;
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') {
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;
43 // Override default features definitions for drupalimagecaption.
44 CKEDITOR.tools.extend(widgetDefinition.features, {
46 requiredContent: 'img[data-caption]'
49 requiredContent: 'img[data-align]'
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;
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';
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);
78 var caption = this.editables.caption;
79 var captionHtml = caption && caption.getData();
80 var attrs = img.attributes;
82 if (captionFilterEnabled) {
83 // If image contains a non-empty caption, serialize caption to the
84 // data-caption attribute.
86 attrs['data-caption'] = captionHtml;
89 if (alignFilterEnabled) {
90 if (this.data.align !== 'none') {
91 attrs['data-align'] = this.data.align;
95 // If img is wrapped with a link, we want to return that link.
96 if (img.parent.name === 'a') {
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']) {
115 // Don't initialize on pasted fake objects.
116 else if (element.attributes['data-cke-realelement']) {
120 element = originalUpcast.call(this, element, data);
121 var attrs = element.attributes;
123 if (element.parent.name === 'a') {
124 element = element.parent;
127 var retElement = element;
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'];
136 if (alignFilterEnabled) {
137 data.align = attrs['data-align'];
138 delete attrs['data-align'];
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'];
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;
155 element.parent.split(index);
157 index = element.getIndex();
159 element.parent.split(index + 1);
162 element.parent.replaceWith(element);
163 retElement = element;
166 // If this image has a caption, create a full <figure> structure.
168 var figure = new CKEDITOR.htmlParser.element('figure');
169 caption = new CKEDITOR.htmlParser.fragment.fromHtml(caption, 'figcaption');
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;
177 element.replaceWith(figure);
180 figure.attributes['class'] = editor.config.image2_captionedClass;
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);
193 // Apply the class for centered images.
194 p.addClass(editor.config.image2_alignClasses[1]);
199 // Return the upcasted element (<img>, <figure> or <p>).
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'
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);
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;
226 var actualWidget = saveCallback(dialogReturnValues);
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);
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));
245 // Low priority to ensure drupalimage's event handler runs first.
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);
258 // Disable alignment buttons if the align filter is not enabled.
259 if (editor.plugins.justify && !editor.config.drupalImageCaption_alignFilterEnabled) {
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);
272 * Finds an element by its name.
274 * Function will check first the passed element itself and then all its
275 * children in DFS order.
277 * @param {CKEDITOR.htmlParser.element} element
278 * The element to search.
279 * @param {string} name
280 * The element name to search for.
282 * @return {?CKEDITOR.htmlParser.element}
283 * The found element, or null.
285 function findElementByName(element, name) {
286 if (element.name === name) {
291 element.forEach(function (el) {
292 if (el.name === name) {
297 }, CKEDITOR.NODE_ELEMENT);