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