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