4f37aa9b93fd89c916aef92d97dc96de285ea274
[yaffs-website] / web / core / modules / ckeditor / js / plugins / drupalimage / plugin.es6.js
1 /**
2  * @file
3  * Drupal Image plugin.
4  *
5  * This alters the existing CKEditor image2 widget plugin to:
6  * - require a data-entity-type and a data-entity-uuid attribute (which Drupal
7  *   uses to track where images are being used)
8  * - use a Drupal-native dialog (that is in fact just an alterable Drupal form
9  *   like any other) instead of CKEditor's own dialogs.
10  *
11  * @see \Drupal\editor\Form\EditorImageDialog
12  *
13  * @ignore
14  */
15
16 (function($, Drupal, CKEDITOR) {
17   /**
18    * Gets the focused widget, if of the type specific for this plugin.
19    *
20    * @param {CKEDITOR.editor} editor
21    *   A CKEditor instance.
22    *
23    * @return {?CKEDITOR.plugins.widget}
24    *   The focused image2 widget instance, or null.
25    */
26   function getFocusedWidget(editor) {
27     const widget = editor.widgets.focused;
28
29     if (widget && widget.name === 'image') {
30       return widget;
31     }
32
33     return null;
34   }
35
36   /**
37    * Integrates the drupalimage widget with the drupallink plugin.
38    *
39    * Makes images linkable.
40    *
41    * @param {CKEDITOR.editor} editor
42    *   A CKEditor instance.
43    */
44   function linkCommandIntegrator(editor) {
45     // Nothing to integrate with if the drupallink plugin is not loaded.
46     if (!editor.plugins.drupallink) {
47       return;
48     }
49
50     // Override default behaviour of 'drupalunlink' command.
51     editor.getCommand('drupalunlink').on('exec', function(evt) {
52       const widget = getFocusedWidget(editor);
53
54       // Override 'drupalunlink' only when link truly belongs to the widget. If
55       // wrapped inline widget in a link, let default unlink work.
56       // @see https://dev.ckeditor.com/ticket/11814
57       if (!widget || !widget.parts.link) {
58         return;
59       }
60
61       widget.setData('link', null);
62
63       // Selection (which is fake) may not change if unlinked image in focused
64       // widget, i.e. if captioned image. Let's refresh command state manually
65       // here.
66       this.refresh(editor, editor.elementPath());
67
68       evt.cancel();
69     });
70
71     // Override default refresh of 'drupalunlink' command.
72     editor.getCommand('drupalunlink').on('refresh', function(evt) {
73       const widget = getFocusedWidget(editor);
74
75       if (!widget) {
76         return;
77       }
78
79       // Note that widget may be wrapped in a link, which
80       // does not belong to that widget (#11814).
81       this.setState(
82         widget.data.link || widget.wrapper.getAscendant('a')
83           ? CKEDITOR.TRISTATE_OFF
84           : CKEDITOR.TRISTATE_DISABLED,
85       );
86
87       evt.cancel();
88     });
89   }
90
91   CKEDITOR.plugins.add('drupalimage', {
92     requires: 'image2',
93     icons: 'drupalimage',
94     hidpi: true,
95
96     beforeInit(editor) {
97       // Override the image2 widget definition to require and handle the
98       // additional data-entity-type and data-entity-uuid attributes.
99       editor.on('widgetDefinition', event => {
100         const widgetDefinition = event.data;
101         if (widgetDefinition.name !== 'image') {
102           return;
103         }
104
105         // First, convert requiredContent & allowedContent from the string
106         // format that image2 uses for both to formats that are better suited
107         // for extending, so that both this basic drupalimage plugin and Drupal
108         // modules can easily extend it.
109         // @see http://docs.ckeditor.com/#!/api/CKEDITOR.filter.allowedContentRules
110         // Mapped from image2's allowedContent. Unlike image2, we don't allow
111         // <figure>, <figcaption>, <div> or <p>  in our downcast, so we omit
112         // those. For the <img> tag, we list all attributes it lists, but omit
113         // the classes, because the listed classes are for alignment, and for
114         // alignment we use the data-align attribute.
115         widgetDefinition.allowedContent = {
116           img: {
117             attributes: {
118               '!src': true,
119               '!alt': true,
120               width: true,
121               height: true,
122             },
123             classes: {},
124           },
125         };
126         // Mapped from image2's requiredContent: "img[src,alt]". This does not
127         // use the object format unlike above, but a CKEDITOR.style instance,
128         // because requiredContent does not support the object format.
129         // @see https://www.drupal.org/node/2585173#comment-10456981
130         widgetDefinition.requiredContent = new CKEDITOR.style({
131           element: 'img',
132           attributes: {
133             src: '',
134             alt: '',
135           },
136         });
137
138         // Extend requiredContent & allowedContent.
139         // CKEDITOR.style is an immutable object: we cannot modify its
140         // definition to extend requiredContent. Hence we get the definition,
141         // modify it, and pass it to a new CKEDITOR.style instance.
142         const requiredContent = widgetDefinition.requiredContent.getDefinition();
143         requiredContent.attributes['data-entity-type'] = '';
144         requiredContent.attributes['data-entity-uuid'] = '';
145         widgetDefinition.requiredContent = new CKEDITOR.style(requiredContent);
146         widgetDefinition.allowedContent.img.attributes[
147           '!data-entity-type'
148         ] = true;
149         widgetDefinition.allowedContent.img.attributes[
150           '!data-entity-uuid'
151         ] = true;
152
153         // Override downcast(): since we only accept <img> in our upcast method,
154         // the element is already correct. We only need to update the element's
155         // data-entity-uuid attribute.
156         widgetDefinition.downcast = function(element) {
157           element.attributes['data-entity-type'] = this.data[
158             'data-entity-type'
159           ];
160           element.attributes['data-entity-uuid'] = this.data[
161             'data-entity-uuid'
162           ];
163         };
164
165         // We want to upcast <img> elements to a DOM structure required by the
166         // image2 widget; we only accept an <img> tag, and that <img> tag MAY
167         // have a data-entity-type and a data-entity-uuid attribute.
168         widgetDefinition.upcast = function(element, data) {
169           if (element.name !== 'img') {
170             return;
171           }
172           // Don't initialize on pasted fake objects.
173           if (element.attributes['data-cke-realelement']) {
174             return;
175           }
176
177           // Parse the data-entity-type attribute.
178           data['data-entity-type'] = element.attributes['data-entity-type'];
179           // Parse the data-entity-uuid attribute.
180           data['data-entity-uuid'] = element.attributes['data-entity-uuid'];
181
182           return element;
183         };
184
185         // Overrides default implementation. Used to populate the "classes"
186         // property of the widget's "data" property, which is used for the
187         // "widget styles" functionality
188         // (http://docs.ckeditor.com/#!/guide/dev_styles-section-widget-styles).
189         // Is applied to whatever the main element of the widget is (<figure> or
190         // <img>). The classes in image2_captionedClass are always added due to
191         // a bug in CKEditor. In the case of drupalimage, we don't ever want to
192         // add that class, because the widget template already contains it.
193         // @see http://dev.ckeditor.com/ticket/13888
194         // @see https://www.drupal.org/node/2268941
195         const originalGetClasses = widgetDefinition.getClasses;
196         widgetDefinition.getClasses = function() {
197           const classes = originalGetClasses.call(this);
198           const captionedClasses = (
199             this.editor.config.image2_captionedClass || ''
200           ).split(/\s+/);
201
202           if (captionedClasses.length && classes) {
203             for (let i = 0; i < captionedClasses.length; i++) {
204               if (captionedClasses[i] in classes) {
205                 delete classes[captionedClasses[i]];
206               }
207             }
208           }
209
210           return classes;
211         };
212
213         // Protected; keys of the widget data to be sent to the Drupal dialog.
214         // Keys in the hash are the keys for image2's data, values are the keys
215         // that the Drupal dialog uses.
216         widgetDefinition._mapDataToDialog = {
217           src: 'src',
218           alt: 'alt',
219           width: 'width',
220           height: 'height',
221           'data-entity-type': 'data-entity-type',
222           'data-entity-uuid': 'data-entity-uuid',
223         };
224
225         // Protected; transforms widget's data object to the format used by the
226         // \Drupal\editor\Form\EditorImageDialog dialog, keeping only the data
227         // listed in widgetDefinition._dataForDialog.
228         widgetDefinition._dataToDialogValues = function(data) {
229           const dialogValues = {};
230           const map = widgetDefinition._mapDataToDialog;
231           Object.keys(widgetDefinition._mapDataToDialog).forEach(key => {
232             dialogValues[map[key]] = data[key];
233           });
234           return dialogValues;
235         };
236
237         // Protected; the inverse of _dataToDialogValues.
238         widgetDefinition._dialogValuesToData = function(dialogReturnValues) {
239           const data = {};
240           const map = widgetDefinition._mapDataToDialog;
241           Object.keys(widgetDefinition._mapDataToDialog).forEach(key => {
242             if (dialogReturnValues.hasOwnProperty(map[key])) {
243               data[key] = dialogReturnValues[map[key]];
244             }
245           });
246           return data;
247         };
248
249         // Protected; creates Drupal dialog save callback.
250         widgetDefinition._createDialogSaveCallback = function(editor, widget) {
251           return function(dialogReturnValues) {
252             const firstEdit = !widget.ready;
253
254             // Dialog may have blurred the widget. Re-focus it first.
255             if (!firstEdit) {
256               widget.focus();
257             }
258
259             editor.fire('saveSnapshot');
260
261             // Pass `true` so DocumentFragment will also be returned.
262             const container = widget.wrapper.getParent(true);
263             const image = widget.parts.image;
264
265             // Set the updated widget data, after the necessary conversions from
266             // the dialog's return values.
267             // Note: on widget#setData this widget instance might be destroyed.
268             const data = widgetDefinition._dialogValuesToData(
269               dialogReturnValues.attributes,
270             );
271             widget.setData(data);
272
273             // Retrieve the widget once again. It could've been destroyed
274             // when shifting state, so might deal with a new instance.
275             widget = editor.widgets.getByElement(image);
276
277             // It's first edit, just after widget instance creation, but before
278             // it was inserted into DOM. So we need to retrieve the widget
279             // wrapper from inside the DocumentFragment which we cached above
280             // and finalize other things (like ready event and flag).
281             if (firstEdit) {
282               editor.widgets.finalizeCreation(container);
283             }
284
285             setTimeout(() => {
286               // (Re-)focus the widget.
287               widget.focus();
288               // Save snapshot for undo support.
289               editor.fire('saveSnapshot');
290             });
291
292             return widget;
293           };
294         };
295
296         const originalInit = widgetDefinition.init;
297         widgetDefinition.init = function() {
298           originalInit.call(this);
299
300           // Update data.link object with attributes if the link has been
301           // discovered.
302           // @see plugins/image2/plugin.js/init() in CKEditor; this is similar.
303           if (this.parts.link) {
304             this.setData(
305               'link',
306               CKEDITOR.plugins.image2.getLinkAttributesParser()(
307                 editor,
308                 this.parts.link,
309               ),
310             );
311           }
312         };
313       });
314
315       // Add a widget#edit listener to every instance of image2 widget in order
316       // to handle its editing with a Drupal-native dialog.
317       // This includes also a case just after the image was created
318       // and dialog should be opened for it for the first time.
319       editor.widgets.on('instanceCreated', event => {
320         const widget = event.data;
321
322         if (widget.name !== 'image') {
323           return;
324         }
325
326         widget.on('edit', event => {
327           // Cancel edit event to break image2's dialog binding
328           // (and also to prevent automatic insertion before opening dialog).
329           event.cancel();
330
331           // Open drupalimage dialog.
332           editor.execCommand('editdrupalimage', {
333             existingValues: widget.definition._dataToDialogValues(widget.data),
334             saveCallback: widget.definition._createDialogSaveCallback(
335               editor,
336               widget,
337             ),
338             // Drupal.t() will not work inside CKEditor plugins because CKEditor
339             // loads the JavaScript file instead of Drupal. Pull translated
340             // strings from the plugin settings that are translated server-side.
341             dialogTitle: widget.data.src
342               ? editor.config.drupalImage_dialogTitleEdit
343               : editor.config.drupalImage_dialogTitleAdd,
344           });
345         });
346       });
347
348       // Register the "editdrupalimage" command, which essentially just replaces
349       // the "image" command's CKEditor dialog with a Drupal-native dialog.
350       editor.addCommand('editdrupalimage', {
351         allowedContent:
352           'img[alt,!src,width,height,!data-entity-type,!data-entity-uuid]',
353         requiredContent: 'img[alt,src,data-entity-type,data-entity-uuid]',
354         modes: { wysiwyg: 1 },
355         canUndo: true,
356         exec(editor, data) {
357           const dialogSettings = {
358             title: data.dialogTitle,
359             dialogClass: 'editor-image-dialog',
360           };
361           Drupal.ckeditor.openDialog(
362             editor,
363             Drupal.url(`editor/dialog/image/${editor.config.drupal.format}`),
364             data.existingValues,
365             data.saveCallback,
366             dialogSettings,
367           );
368         },
369       });
370
371       // Register the toolbar button.
372       if (editor.ui.addButton) {
373         editor.ui.addButton('DrupalImage', {
374           label: Drupal.t('Image'),
375           // Note that we use the original image2 command!
376           command: 'image',
377         });
378       }
379     },
380
381     afterInit(editor) {
382       linkCommandIntegrator(editor);
383     },
384   });
385
386   // Override image2's integration with the official CKEditor link plugin:
387   // integrate with the drupallink plugin instead.
388   CKEDITOR.plugins.image2.getLinkAttributesParser = function() {
389     return CKEDITOR.plugins.drupallink.parseLinkAttributes;
390   };
391   CKEDITOR.plugins.image2.getLinkAttributesGetter = function() {
392     return CKEDITOR.plugins.drupallink.getLinkAttributes;
393   };
394
395   // Expose an API for other plugins to interact with drupalimage widgets.
396   CKEDITOR.plugins.drupalimage = {
397     getFocusedWidget,
398   };
399 })(jQuery, Drupal, CKEDITOR);