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.
11 * @see \Drupal\editor\Form\EditorImageDialog
16 (function($, Drupal, CKEDITOR) {
18 * Gets the focused widget, if of the type specific for this plugin.
20 * @param {CKEDITOR.editor} editor
21 * A CKEditor instance.
23 * @return {?CKEDITOR.plugins.widget}
24 * The focused image2 widget instance, or null.
26 function getFocusedWidget(editor) {
27 const widget = editor.widgets.focused;
29 if (widget && widget.name === 'image') {
37 * Integrates the drupalimage widget with the drupallink plugin.
39 * Makes images linkable.
41 * @param {CKEDITOR.editor} editor
42 * A CKEditor instance.
44 function linkCommandIntegrator(editor) {
45 // Nothing to integrate with if the drupallink plugin is not loaded.
46 if (!editor.plugins.drupallink) {
50 // Override default behaviour of 'drupalunlink' command.
51 editor.getCommand('drupalunlink').on('exec', function(evt) {
52 const widget = getFocusedWidget(editor);
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) {
61 widget.setData('link', null);
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
66 this.refresh(editor, editor.elementPath());
71 // Override default refresh of 'drupalunlink' command.
72 editor.getCommand('drupalunlink').on('refresh', function(evt) {
73 const widget = getFocusedWidget(editor);
79 // Note that widget may be wrapped in a link, which
80 // does not belong to that widget (#11814).
82 widget.data.link || widget.wrapper.getAscendant('a')
83 ? CKEDITOR.TRISTATE_OFF
84 : CKEDITOR.TRISTATE_DISABLED,
91 CKEDITOR.plugins.add('drupalimage', {
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') {
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 = {
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({
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[
149 widgetDefinition.allowedContent.img.attributes[
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[
160 element.attributes['data-entity-uuid'] = this.data[
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') {
172 // Don't initialize on pasted fake objects.
173 if (element.attributes['data-cke-realelement']) {
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'];
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 || ''
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]];
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 = {
221 'data-entity-type': 'data-entity-type',
222 'data-entity-uuid': 'data-entity-uuid',
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];
237 // Protected; the inverse of _dataToDialogValues.
238 widgetDefinition._dialogValuesToData = function(dialogReturnValues) {
240 const map = widgetDefinition._mapDataToDialog;
241 Object.keys(widgetDefinition._mapDataToDialog).forEach(key => {
242 if (dialogReturnValues.hasOwnProperty(map[key])) {
243 data[key] = dialogReturnValues[map[key]];
249 // Protected; creates Drupal dialog save callback.
250 widgetDefinition._createDialogSaveCallback = function(editor, widget) {
251 return function(dialogReturnValues) {
252 const firstEdit = !widget.ready;
254 // Dialog may have blurred the widget. Re-focus it first.
259 editor.fire('saveSnapshot');
261 // Pass `true` so DocumentFragment will also be returned.
262 const container = widget.wrapper.getParent(true);
263 const image = widget.parts.image;
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,
271 widget.setData(data);
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);
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).
282 editor.widgets.finalizeCreation(container);
286 // (Re-)focus the widget.
288 // Save snapshot for undo support.
289 editor.fire('saveSnapshot');
296 const originalInit = widgetDefinition.init;
297 widgetDefinition.init = function() {
298 originalInit.call(this);
300 // Update data.link object with attributes if the link has been
302 // @see plugins/image2/plugin.js/init() in CKEditor; this is similar.
303 if (this.parts.link) {
306 CKEDITOR.plugins.image2.getLinkAttributesParser()(
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;
322 if (widget.name !== 'image') {
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).
331 // Open drupalimage dialog.
332 editor.execCommand('editdrupalimage', {
333 existingValues: widget.definition._dataToDialogValues(widget.data),
334 saveCallback: widget.definition._createDialogSaveCallback(
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,
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', {
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 },
357 const dialogSettings = {
358 title: data.dialogTitle,
359 dialogClass: 'editor-image-dialog',
361 Drupal.ckeditor.openDialog(
363 Drupal.url(`editor/dialog/image/${editor.config.drupal.format}`),
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!
382 linkCommandIntegrator(editor);
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;
391 CKEDITOR.plugins.image2.getLinkAttributesGetter = function() {
392 return CKEDITOR.plugins.drupallink.getLinkAttributes;
395 // Expose an API for other plugins to interact with drupalimage widgets.
396 CKEDITOR.plugins.drupalimage = {
399 })(jQuery, Drupal, CKEDITOR);