Added Entity and Entity Reference Revisions which got dropped somewhere along the...
[yaffs-website] / web / core / modules / ckeditor / js / views / ControllerView.es6.js
1 /**
2  * @file
3  * A Backbone View acting as a controller for CKEditor toolbar configuration.
4  */
5
6 (function($, Drupal, Backbone, CKEDITOR, _) {
7   Drupal.ckeditor.ControllerView = Backbone.View.extend(
8     /** @lends Drupal.ckeditor.ControllerView# */ {
9       /**
10        * @type {object}
11        */
12       events: {},
13
14       /**
15        * Backbone View acting as a controller for CKEditor toolbar configuration.
16        *
17        * @constructs
18        *
19        * @augments Backbone.View
20        */
21       initialize() {
22         this.getCKEditorFeatures(
23           this.model.get('hiddenEditorConfig'),
24           this.disableFeaturesDisallowedByFilters.bind(this),
25         );
26
27         // Push the active editor configuration to the textarea.
28         this.model.listenTo(
29           this.model,
30           'change:activeEditorConfig',
31           this.model.sync,
32         );
33         this.listenTo(this.model, 'change:isDirty', this.parseEditorDOM);
34       },
35
36       /**
37        * Converts the active toolbar DOM structure to an object representation.
38        *
39        * @param {Drupal.ckeditor.ConfigurationModel} model
40        *   The state model for the CKEditor configuration.
41        * @param {bool} isDirty
42        *   Tracks whether the active toolbar DOM structure has been changed.
43        *   isDirty is toggled back to false in this method.
44        * @param {object} options
45        *   An object that includes:
46        * @param {bool} [options.broadcast]
47        *   A flag that controls whether a CKEditorToolbarChanged event should be
48        *   fired for configuration changes.
49        *
50        * @fires event:CKEditorToolbarChanged
51        */
52       parseEditorDOM(model, isDirty, options) {
53         if (isDirty) {
54           const currentConfig = this.model.get('activeEditorConfig');
55
56           // Process the rows.
57           const rows = [];
58           this.$el
59             .find('.ckeditor-active-toolbar-configuration')
60             .children('.ckeditor-row')
61             .each(function() {
62               const groups = [];
63               // Process the button groups.
64               $(this)
65                 .find('.ckeditor-toolbar-group')
66                 .each(function() {
67                   const $group = $(this);
68                   const $buttons = $group.find('.ckeditor-button');
69                   if ($buttons.length) {
70                     const group = {
71                       name: $group.attr(
72                         'data-drupal-ckeditor-toolbar-group-name',
73                       ),
74                       items: [],
75                     };
76                     $group
77                       .find('.ckeditor-button, .ckeditor-multiple-button')
78                       .each(function() {
79                         group.items.push(
80                           $(this).attr('data-drupal-ckeditor-button-name'),
81                         );
82                       });
83                     groups.push(group);
84                   }
85                 });
86               if (groups.length) {
87                 rows.push(groups);
88               }
89             });
90           this.model.set('activeEditorConfig', rows);
91           // Mark the model as clean. Whether or not the sync to the textfield
92           // occurs depends on the activeEditorConfig attribute firing a change
93           // event. The DOM has at least been processed and posted, so as far as
94           // the model is concerned, it is clean.
95           this.model.set('isDirty', false);
96
97           // Determine whether we should trigger an event.
98           if (options.broadcast !== false) {
99             const prev = this.getButtonList(currentConfig);
100             const next = this.getButtonList(rows);
101             if (prev.length !== next.length) {
102               this.$el
103                 .find('.ckeditor-toolbar-active')
104                 .trigger('CKEditorToolbarChanged', [
105                   prev.length < next.length ? 'added' : 'removed',
106                   _.difference(
107                     _.union(prev, next),
108                     _.intersection(prev, next),
109                   )[0],
110                 ]);
111             }
112           }
113         }
114       },
115
116       /**
117        * Asynchronously retrieve the metadata for all available CKEditor features.
118        *
119        * In order to get a list of all features needed by CKEditor, we create a
120        * hidden CKEditor instance, then check the CKEditor's "allowedContent"
121        * filter settings. Because creating an instance is expensive, a callback
122        * must be provided that will receive a hash of {@link Drupal.EditorFeature}
123        * features keyed by feature (button) name.
124        *
125        * @param {object} CKEditorConfig
126        *   An object that represents the configuration settings for a CKEditor
127        *   editor component.
128        * @param {function} callback
129        *   A function to invoke when the instanceReady event is fired by the
130        *   CKEditor object.
131        */
132       getCKEditorFeatures(CKEditorConfig, callback) {
133         const getProperties = function(CKEPropertiesList) {
134           return _.isObject(CKEPropertiesList) ? _.keys(CKEPropertiesList) : [];
135         };
136
137         const convertCKERulesToEditorFeature = function(
138           feature,
139           CKEFeatureRules,
140         ) {
141           for (let i = 0; i < CKEFeatureRules.length; i++) {
142             const CKERule = CKEFeatureRules[i];
143             const rule = new Drupal.EditorFeatureHTMLRule();
144
145             // Tags.
146             const tags = getProperties(CKERule.elements);
147             rule.required.tags = CKERule.propertiesOnly ? [] : tags;
148             rule.allowed.tags = tags;
149             // Attributes.
150             rule.required.attributes = getProperties(
151               CKERule.requiredAttributes,
152             );
153             rule.allowed.attributes = getProperties(CKERule.attributes);
154             // Styles.
155             rule.required.styles = getProperties(CKERule.requiredStyles);
156             rule.allowed.styles = getProperties(CKERule.styles);
157             // Classes.
158             rule.required.classes = getProperties(CKERule.requiredClasses);
159             rule.allowed.classes = getProperties(CKERule.classes);
160             // Raw.
161             rule.raw = CKERule;
162
163             feature.addHTMLRule(rule);
164           }
165         };
166
167         // Create hidden CKEditor with all features enabled, retrieve metadata.
168         // @see \Drupal\ckeditor\Plugin\Editor\CKEditor::buildConfigurationForm().
169         const hiddenCKEditorID = 'ckeditor-hidden';
170         if (CKEDITOR.instances[hiddenCKEditorID]) {
171           CKEDITOR.instances[hiddenCKEditorID].destroy(true);
172         }
173         // Load external plugins, if any.
174         const hiddenEditorConfig = this.model.get('hiddenEditorConfig');
175         if (hiddenEditorConfig.drupalExternalPlugins) {
176           const externalPlugins = hiddenEditorConfig.drupalExternalPlugins;
177           Object.keys(externalPlugins || {}).forEach(pluginName => {
178             CKEDITOR.plugins.addExternal(
179               pluginName,
180               externalPlugins[pluginName],
181               '',
182             );
183           });
184         }
185         CKEDITOR.inline($(`#${hiddenCKEditorID}`).get(0), CKEditorConfig);
186
187         // Once the instance is ready, retrieve the allowedContent filter rules
188         // and convert them to Drupal.EditorFeature objects.
189         CKEDITOR.once('instanceReady', e => {
190           if (e.editor.name === hiddenCKEditorID) {
191             // First collect all CKEditor allowedContent rules.
192             const CKEFeatureRulesMap = {};
193             const rules = e.editor.filter.allowedContent;
194             let rule;
195             let name;
196             for (let i = 0; i < rules.length; i++) {
197               rule = rules[i];
198               name = rule.featureName || ':(';
199               if (!CKEFeatureRulesMap[name]) {
200                 CKEFeatureRulesMap[name] = [];
201               }
202               CKEFeatureRulesMap[name].push(rule);
203             }
204
205             // Now convert these to Drupal.EditorFeature objects. And track which
206             // buttons are mapped to which features.
207             // @see getFeatureForButton()
208             const features = {};
209             const buttonsToFeatures = {};
210             Object.keys(CKEFeatureRulesMap).forEach(featureName => {
211               const feature = new Drupal.EditorFeature(featureName);
212               convertCKERulesToEditorFeature(
213                 feature,
214                 CKEFeatureRulesMap[featureName],
215               );
216               features[featureName] = feature;
217               const command = e.editor.getCommand(featureName);
218               if (command) {
219                 buttonsToFeatures[command.uiItems[0].name] = featureName;
220               }
221             });
222
223             callback(features, buttonsToFeatures);
224           }
225         });
226       },
227
228       /**
229        * Retrieves the feature for a given button from featuresMetadata. Returns
230        * false if the given button is in fact a divider.
231        *
232        * @param {string} button
233        *   The name of a CKEditor button.
234        *
235        * @return {object}
236        *   The feature metadata object for a button.
237        */
238       getFeatureForButton(button) {
239         // Return false if the button being added is a divider.
240         if (button === '-') {
241           return false;
242         }
243
244         // Get a Drupal.editorFeature object that contains all metadata for
245         // the feature that was just added or removed. Not every feature has
246         // such metadata.
247         let featureName = this.model.get('buttonsToFeatures')[
248           button.toLowerCase()
249         ];
250         // Features without an associated command do not have a 'feature name' by
251         // default, so we use the lowercased button name instead.
252         if (!featureName) {
253           featureName = button.toLowerCase();
254         }
255         const featuresMetadata = this.model.get('featuresMetadata');
256         if (!featuresMetadata[featureName]) {
257           featuresMetadata[featureName] = new Drupal.EditorFeature(featureName);
258           this.model.set('featuresMetadata', featuresMetadata);
259         }
260         return featuresMetadata[featureName];
261       },
262
263       /**
264        * Checks buttons against filter settings; disables disallowed buttons.
265        *
266        * @param {object} features
267        *   A map of {@link Drupal.EditorFeature} objects.
268        * @param {object} buttonsToFeatures
269        *   Object containing the button-to-feature mapping.
270        *
271        * @see Drupal.ckeditor.ControllerView#getFeatureForButton
272        */
273       disableFeaturesDisallowedByFilters(features, buttonsToFeatures) {
274         this.model.set('featuresMetadata', features);
275         // Store the button-to-feature mapping. Needs to happen only once, because
276         // the same buttons continue to have the same features; only the rules for
277         // specific features may change.
278         // @see getFeatureForButton()
279         this.model.set('buttonsToFeatures', buttonsToFeatures);
280
281         // Ensure that toolbar configuration changes are broadcast.
282         this.broadcastConfigurationChanges(this.$el);
283
284         // Initialization: not all of the default toolbar buttons may be allowed
285         // by the current filter settings. Remove any of the default toolbar
286         // buttons that require more permissive filter settings. The remaining
287         // default toolbar buttons are marked as "added".
288         let existingButtons = [];
289         // Loop through each button group after flattening the groups from the
290         // toolbar row arrays.
291         const buttonGroups = _.flatten(this.model.get('activeEditorConfig'));
292         for (let i = 0; i < buttonGroups.length; i++) {
293           // Pull the button names from each toolbar button group.
294           const buttons = buttonGroups[i].items;
295           for (let k = 0; k < buttons.length; k++) {
296             existingButtons.push(buttons[k]);
297           }
298         }
299         // Remove duplicate buttons.
300         existingButtons = _.unique(existingButtons);
301         // Prepare the active toolbar and available-button toolbars.
302         for (let n = 0; n < existingButtons.length; n++) {
303           const button = existingButtons[n];
304           const feature = this.getFeatureForButton(button);
305           // Skip dividers.
306           if (feature === false) {
307             continue;
308           }
309
310           if (Drupal.editorConfiguration.featureIsAllowedByFilters(feature)) {
311             // Existing toolbar buttons are in fact "added features".
312             this.$el
313               .find('.ckeditor-toolbar-active')
314               .trigger('CKEditorToolbarChanged', ['added', existingButtons[n]]);
315           } else {
316             // Move the button element from the active the active toolbar to the
317             // list of available buttons.
318             $(
319               `.ckeditor-toolbar-active li[data-drupal-ckeditor-button-name="${button}"]`,
320             )
321               .detach()
322               .appendTo(
323                 '.ckeditor-toolbar-disabled > .ckeditor-toolbar-available > ul',
324               );
325             // Update the toolbar value field.
326             this.model.set({ isDirty: true }, { broadcast: false });
327           }
328         }
329       },
330
331       /**
332        * Sets up broadcasting of CKEditor toolbar configuration changes.
333        *
334        * @param {jQuery} $ckeditorToolbar
335        *   The active toolbar DOM element wrapped in jQuery.
336        */
337       broadcastConfigurationChanges($ckeditorToolbar) {
338         const view = this;
339         const hiddenEditorConfig = this.model.get('hiddenEditorConfig');
340         const getFeatureForButton = this.getFeatureForButton.bind(this);
341         const getCKEditorFeatures = this.getCKEditorFeatures.bind(this);
342         $ckeditorToolbar
343           .find('.ckeditor-toolbar-active')
344           // Listen for CKEditor toolbar configuration changes. When a button is
345           // added/removed, call an appropriate Drupal.editorConfiguration method.
346           .on(
347             'CKEditorToolbarChanged.ckeditorAdmin',
348             (event, action, button) => {
349               const feature = getFeatureForButton(button);
350
351               // Early-return if the button being added is a divider.
352               if (feature === false) {
353                 return;
354               }
355
356               // Trigger a standardized text editor configuration event to indicate
357               // whether a feature was added or removed, so that filters can react.
358               const configEvent =
359                 action === 'added' ? 'addedFeature' : 'removedFeature';
360               Drupal.editorConfiguration[configEvent](feature);
361             },
362           )
363           // Listen for CKEditor plugin settings changes. When a plugin setting is
364           // changed, rebuild the CKEditor features metadata.
365           .on(
366             'CKEditorPluginSettingsChanged.ckeditorAdmin',
367             (event, settingsChanges) => {
368               // Update hidden CKEditor configuration.
369               Object.keys(settingsChanges || {}).forEach(key => {
370                 hiddenEditorConfig[key] = settingsChanges[key];
371               });
372
373               // Retrieve features for the updated hidden CKEditor configuration.
374               getCKEditorFeatures(hiddenEditorConfig, features => {
375                 // Trigger a standardized text editor configuration event for each
376                 // feature that was modified by the configuration changes.
377                 const featuresMetadata = view.model.get('featuresMetadata');
378                 Object.keys(features || {}).forEach(name => {
379                   const feature = features[name];
380                   if (
381                     featuresMetadata.hasOwnProperty(name) &&
382                     !_.isEqual(featuresMetadata[name], feature)
383                   ) {
384                     Drupal.editorConfiguration.modifiedFeature(feature);
385                   }
386                 });
387                 // Update the CKEditor features metadata.
388                 view.model.set('featuresMetadata', features);
389               });
390             },
391           );
392       },
393
394       /**
395        * Returns the list of buttons from an editor configuration.
396        *
397        * @param {object} config
398        *   A CKEditor configuration object.
399        *
400        * @return {Array}
401        *   A list of buttons in the CKEditor configuration.
402        */
403       getButtonList(config) {
404         const buttons = [];
405         // Remove the rows.
406         config = _.flatten(config);
407
408         // Loop through the button groups and pull out the buttons.
409         config.forEach(group => {
410           group.items.forEach(button => {
411             buttons.push(button);
412           });
413         });
414
415         // Remove the dividing elements if any.
416         return _.without(buttons, '-');
417       },
418     },
419   );
420 })(jQuery, Drupal, Backbone, CKEDITOR, _);