Chris@0: /** Chris@0: * @file Chris@0: * A Backbone View acting as a controller for CKEditor toolbar configuration. Chris@0: */ Chris@0: Chris@17: (function($, Drupal, Backbone, CKEDITOR, _) { Chris@17: Drupal.ckeditor.ControllerView = Backbone.View.extend( Chris@17: /** @lends Drupal.ckeditor.ControllerView# */ { Chris@17: /** Chris@17: * @type {object} Chris@17: */ Chris@17: events: {}, Chris@0: Chris@17: /** Chris@17: * Backbone View acting as a controller for CKEditor toolbar configuration. Chris@17: * Chris@17: * @constructs Chris@17: * Chris@17: * @augments Backbone.View Chris@17: */ Chris@17: initialize() { Chris@17: this.getCKEditorFeatures( Chris@17: this.model.get('hiddenEditorConfig'), Chris@17: this.disableFeaturesDisallowedByFilters.bind(this), Chris@17: ); Chris@0: Chris@17: // Push the active editor configuration to the textarea. Chris@17: this.model.listenTo( Chris@17: this.model, Chris@17: 'change:activeEditorConfig', Chris@17: this.model.sync, Chris@17: ); Chris@17: this.listenTo(this.model, 'change:isDirty', this.parseEditorDOM); Chris@17: }, Chris@0: Chris@17: /** Chris@17: * Converts the active toolbar DOM structure to an object representation. Chris@17: * Chris@17: * @param {Drupal.ckeditor.ConfigurationModel} model Chris@17: * The state model for the CKEditor configuration. Chris@17: * @param {bool} isDirty Chris@17: * Tracks whether the active toolbar DOM structure has been changed. Chris@17: * isDirty is toggled back to false in this method. Chris@17: * @param {object} options Chris@17: * An object that includes: Chris@17: * @param {bool} [options.broadcast] Chris@17: * A flag that controls whether a CKEditorToolbarChanged event should be Chris@17: * fired for configuration changes. Chris@17: * Chris@17: * @fires event:CKEditorToolbarChanged Chris@17: */ Chris@17: parseEditorDOM(model, isDirty, options) { Chris@17: if (isDirty) { Chris@17: const currentConfig = this.model.get('activeEditorConfig'); Chris@0: Chris@17: // Process the rows. Chris@17: const rows = []; Chris@17: this.$el Chris@17: .find('.ckeditor-active-toolbar-configuration') Chris@17: .children('.ckeditor-row') Chris@17: .each(function() { Chris@17: const groups = []; Chris@17: // Process the button groups. Chris@17: $(this) Chris@17: .find('.ckeditor-toolbar-group') Chris@17: .each(function() { Chris@17: const $group = $(this); Chris@17: const $buttons = $group.find('.ckeditor-button'); Chris@17: if ($buttons.length) { Chris@17: const group = { Chris@17: name: $group.attr( Chris@17: 'data-drupal-ckeditor-toolbar-group-name', Chris@17: ), Chris@17: items: [], Chris@17: }; Chris@17: $group Chris@17: .find('.ckeditor-button, .ckeditor-multiple-button') Chris@17: .each(function() { Chris@17: group.items.push( Chris@17: $(this).attr('data-drupal-ckeditor-button-name'), Chris@17: ); Chris@17: }); Chris@17: groups.push(group); Chris@17: } Chris@0: }); Chris@17: if (groups.length) { Chris@17: rows.push(groups); Chris@0: } Chris@0: }); Chris@17: this.model.set('activeEditorConfig', rows); Chris@17: // Mark the model as clean. Whether or not the sync to the textfield Chris@17: // occurs depends on the activeEditorConfig attribute firing a change Chris@17: // event. The DOM has at least been processed and posted, so as far as Chris@17: // the model is concerned, it is clean. Chris@17: this.model.set('isDirty', false); Chris@17: Chris@17: // Determine whether we should trigger an event. Chris@17: if (options.broadcast !== false) { Chris@17: const prev = this.getButtonList(currentConfig); Chris@17: const next = this.getButtonList(rows); Chris@17: if (prev.length !== next.length) { Chris@17: this.$el Chris@17: .find('.ckeditor-toolbar-active') Chris@17: .trigger('CKEditorToolbarChanged', [ Chris@17: prev.length < next.length ? 'added' : 'removed', Chris@17: _.difference( Chris@17: _.union(prev, next), Chris@17: _.intersection(prev, next), Chris@17: )[0], Chris@17: ]); Chris@0: } Chris@17: } Chris@17: } Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Asynchronously retrieve the metadata for all available CKEditor features. Chris@17: * Chris@17: * In order to get a list of all features needed by CKEditor, we create a Chris@17: * hidden CKEditor instance, then check the CKEditor's "allowedContent" Chris@17: * filter settings. Because creating an instance is expensive, a callback Chris@17: * must be provided that will receive a hash of {@link Drupal.EditorFeature} Chris@17: * features keyed by feature (button) name. Chris@17: * Chris@17: * @param {object} CKEditorConfig Chris@17: * An object that represents the configuration settings for a CKEditor Chris@17: * editor component. Chris@17: * @param {function} callback Chris@17: * A function to invoke when the instanceReady event is fired by the Chris@17: * CKEditor object. Chris@17: */ Chris@17: getCKEditorFeatures(CKEditorConfig, callback) { Chris@17: const getProperties = function(CKEPropertiesList) { Chris@17: return _.isObject(CKEPropertiesList) ? _.keys(CKEPropertiesList) : []; Chris@17: }; Chris@17: Chris@17: const convertCKERulesToEditorFeature = function( Chris@17: feature, Chris@17: CKEFeatureRules, Chris@17: ) { Chris@17: for (let i = 0; i < CKEFeatureRules.length; i++) { Chris@17: const CKERule = CKEFeatureRules[i]; Chris@17: const rule = new Drupal.EditorFeatureHTMLRule(); Chris@17: Chris@17: // Tags. Chris@17: const tags = getProperties(CKERule.elements); Chris@17: rule.required.tags = CKERule.propertiesOnly ? [] : tags; Chris@17: rule.allowed.tags = tags; Chris@17: // Attributes. Chris@17: rule.required.attributes = getProperties( Chris@17: CKERule.requiredAttributes, Chris@17: ); Chris@17: rule.allowed.attributes = getProperties(CKERule.attributes); Chris@17: // Styles. Chris@17: rule.required.styles = getProperties(CKERule.requiredStyles); Chris@17: rule.allowed.styles = getProperties(CKERule.styles); Chris@17: // Classes. Chris@17: rule.required.classes = getProperties(CKERule.requiredClasses); Chris@17: rule.allowed.classes = getProperties(CKERule.classes); Chris@17: // Raw. Chris@17: rule.raw = CKERule; Chris@17: Chris@17: feature.addHTMLRule(rule); Chris@17: } Chris@17: }; Chris@17: Chris@17: // Create hidden CKEditor with all features enabled, retrieve metadata. Chris@17: // @see \Drupal\ckeditor\Plugin\Editor\CKEditor::buildConfigurationForm(). Chris@17: const hiddenCKEditorID = 'ckeditor-hidden'; Chris@17: if (CKEDITOR.instances[hiddenCKEditorID]) { Chris@17: CKEDITOR.instances[hiddenCKEditorID].destroy(true); Chris@17: } Chris@17: // Load external plugins, if any. Chris@17: const hiddenEditorConfig = this.model.get('hiddenEditorConfig'); Chris@17: if (hiddenEditorConfig.drupalExternalPlugins) { Chris@17: const externalPlugins = hiddenEditorConfig.drupalExternalPlugins; Chris@17: Object.keys(externalPlugins || {}).forEach(pluginName => { Chris@17: CKEDITOR.plugins.addExternal( Chris@17: pluginName, Chris@17: externalPlugins[pluginName], Chris@17: '', Chris@17: ); Chris@0: }); Chris@17: } Chris@17: CKEDITOR.inline($(`#${hiddenCKEditorID}`).get(0), CKEditorConfig); Chris@0: Chris@17: // Once the instance is ready, retrieve the allowedContent filter rules Chris@17: // and convert them to Drupal.EditorFeature objects. Chris@17: CKEDITOR.once('instanceReady', e => { Chris@17: if (e.editor.name === hiddenCKEditorID) { Chris@17: // First collect all CKEditor allowedContent rules. Chris@17: const CKEFeatureRulesMap = {}; Chris@17: const rules = e.editor.filter.allowedContent; Chris@17: let rule; Chris@17: let name; Chris@17: for (let i = 0; i < rules.length; i++) { Chris@17: rule = rules[i]; Chris@17: name = rule.featureName || ':('; Chris@17: if (!CKEFeatureRulesMap[name]) { Chris@17: CKEFeatureRulesMap[name] = []; Chris@17: } Chris@17: CKEFeatureRulesMap[name].push(rule); Chris@17: } Chris@17: Chris@17: // Now convert these to Drupal.EditorFeature objects. And track which Chris@17: // buttons are mapped to which features. Chris@17: // @see getFeatureForButton() Chris@17: const features = {}; Chris@17: const buttonsToFeatures = {}; Chris@17: Object.keys(CKEFeatureRulesMap).forEach(featureName => { Chris@17: const feature = new Drupal.EditorFeature(featureName); Chris@17: convertCKERulesToEditorFeature( Chris@17: feature, Chris@17: CKEFeatureRulesMap[featureName], Chris@17: ); Chris@17: features[featureName] = feature; Chris@17: const command = e.editor.getCommand(featureName); Chris@17: if (command) { Chris@17: buttonsToFeatures[command.uiItems[0].name] = featureName; Chris@17: } Chris@17: }); Chris@17: Chris@17: callback(features, buttonsToFeatures); Chris@17: } Chris@17: }); Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Retrieves the feature for a given button from featuresMetadata. Returns Chris@17: * false if the given button is in fact a divider. Chris@17: * Chris@17: * @param {string} button Chris@17: * The name of a CKEditor button. Chris@17: * Chris@17: * @return {object} Chris@17: * The feature metadata object for a button. Chris@17: */ Chris@17: getFeatureForButton(button) { Chris@17: // Return false if the button being added is a divider. Chris@17: if (button === '-') { Chris@17: return false; Chris@17: } Chris@17: Chris@17: // Get a Drupal.editorFeature object that contains all metadata for Chris@17: // the feature that was just added or removed. Not every feature has Chris@17: // such metadata. Chris@17: let featureName = this.model.get('buttonsToFeatures')[ Chris@17: button.toLowerCase() Chris@17: ]; Chris@17: // Features without an associated command do not have a 'feature name' by Chris@17: // default, so we use the lowercased button name instead. Chris@17: if (!featureName) { Chris@17: featureName = button.toLowerCase(); Chris@17: } Chris@17: const featuresMetadata = this.model.get('featuresMetadata'); Chris@17: if (!featuresMetadata[featureName]) { Chris@17: featuresMetadata[featureName] = new Drupal.EditorFeature(featureName); Chris@17: this.model.set('featuresMetadata', featuresMetadata); Chris@17: } Chris@17: return featuresMetadata[featureName]; Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Checks buttons against filter settings; disables disallowed buttons. Chris@17: * Chris@17: * @param {object} features Chris@17: * A map of {@link Drupal.EditorFeature} objects. Chris@17: * @param {object} buttonsToFeatures Chris@17: * Object containing the button-to-feature mapping. Chris@17: * Chris@17: * @see Drupal.ckeditor.ControllerView#getFeatureForButton Chris@17: */ Chris@17: disableFeaturesDisallowedByFilters(features, buttonsToFeatures) { Chris@17: this.model.set('featuresMetadata', features); Chris@17: // Store the button-to-feature mapping. Needs to happen only once, because Chris@17: // the same buttons continue to have the same features; only the rules for Chris@17: // specific features may change. Chris@17: // @see getFeatureForButton() Chris@17: this.model.set('buttonsToFeatures', buttonsToFeatures); Chris@17: Chris@17: // Ensure that toolbar configuration changes are broadcast. Chris@17: this.broadcastConfigurationChanges(this.$el); Chris@17: Chris@17: // Initialization: not all of the default toolbar buttons may be allowed Chris@17: // by the current filter settings. Remove any of the default toolbar Chris@17: // buttons that require more permissive filter settings. The remaining Chris@17: // default toolbar buttons are marked as "added". Chris@17: let existingButtons = []; Chris@17: // Loop through each button group after flattening the groups from the Chris@17: // toolbar row arrays. Chris@17: const buttonGroups = _.flatten(this.model.get('activeEditorConfig')); Chris@17: for (let i = 0; i < buttonGroups.length; i++) { Chris@17: // Pull the button names from each toolbar button group. Chris@17: const buttons = buttonGroups[i].items; Chris@17: for (let k = 0; k < buttons.length; k++) { Chris@17: existingButtons.push(buttons[k]); Chris@17: } Chris@17: } Chris@17: // Remove duplicate buttons. Chris@17: existingButtons = _.unique(existingButtons); Chris@17: // Prepare the active toolbar and available-button toolbars. Chris@17: for (let n = 0; n < existingButtons.length; n++) { Chris@17: const button = existingButtons[n]; Chris@17: const feature = this.getFeatureForButton(button); Chris@17: // Skip dividers. Chris@17: if (feature === false) { Chris@17: continue; Chris@17: } Chris@17: Chris@17: if (Drupal.editorConfiguration.featureIsAllowedByFilters(feature)) { Chris@17: // Existing toolbar buttons are in fact "added features". Chris@0: this.$el Chris@0: .find('.ckeditor-toolbar-active') Chris@17: .trigger('CKEditorToolbarChanged', ['added', existingButtons[n]]); Chris@17: } else { Chris@17: // Move the button element from the active the active toolbar to the Chris@17: // list of available buttons. Chris@17: $( Chris@17: `.ckeditor-toolbar-active li[data-drupal-ckeditor-button-name="${button}"]`, Chris@17: ) Chris@17: .detach() Chris@17: .appendTo( Chris@17: '.ckeditor-toolbar-disabled > .ckeditor-toolbar-available > ul', Chris@17: ); Chris@17: // Update the toolbar value field. Chris@17: this.model.set({ isDirty: true }, { broadcast: false }); Chris@0: } Chris@0: } Chris@17: }, Chris@0: Chris@17: /** Chris@17: * Sets up broadcasting of CKEditor toolbar configuration changes. Chris@17: * Chris@17: * @param {jQuery} $ckeditorToolbar Chris@17: * The active toolbar DOM element wrapped in jQuery. Chris@17: */ Chris@17: broadcastConfigurationChanges($ckeditorToolbar) { Chris@17: const view = this; Chris@17: const hiddenEditorConfig = this.model.get('hiddenEditorConfig'); Chris@17: const getFeatureForButton = this.getFeatureForButton.bind(this); Chris@17: const getCKEditorFeatures = this.getCKEditorFeatures.bind(this); Chris@17: $ckeditorToolbar Chris@17: .find('.ckeditor-toolbar-active') Chris@17: // Listen for CKEditor toolbar configuration changes. When a button is Chris@17: // added/removed, call an appropriate Drupal.editorConfiguration method. Chris@17: .on( Chris@17: 'CKEditorToolbarChanged.ckeditorAdmin', Chris@17: (event, action, button) => { Chris@17: const feature = getFeatureForButton(button); Chris@0: Chris@17: // Early-return if the button being added is a divider. Chris@17: if (feature === false) { Chris@17: return; Chris@17: } Chris@0: Chris@17: // Trigger a standardized text editor configuration event to indicate Chris@17: // whether a feature was added or removed, so that filters can react. Chris@17: const configEvent = Chris@17: action === 'added' ? 'addedFeature' : 'removedFeature'; Chris@17: Drupal.editorConfiguration[configEvent](feature); Chris@17: }, Chris@17: ) Chris@17: // Listen for CKEditor plugin settings changes. When a plugin setting is Chris@17: // changed, rebuild the CKEditor features metadata. Chris@17: .on( Chris@17: 'CKEditorPluginSettingsChanged.ckeditorAdmin', Chris@17: (event, settingsChanges) => { Chris@17: // Update hidden CKEditor configuration. Chris@17: Object.keys(settingsChanges || {}).forEach(key => { Chris@17: hiddenEditorConfig[key] = settingsChanges[key]; Chris@17: }); Chris@0: Chris@17: // Retrieve features for the updated hidden CKEditor configuration. Chris@17: getCKEditorFeatures(hiddenEditorConfig, features => { Chris@17: // Trigger a standardized text editor configuration event for each Chris@17: // feature that was modified by the configuration changes. Chris@17: const featuresMetadata = view.model.get('featuresMetadata'); Chris@17: Object.keys(features || {}).forEach(name => { Chris@17: const feature = features[name]; Chris@17: if ( Chris@17: featuresMetadata.hasOwnProperty(name) && Chris@17: !_.isEqual(featuresMetadata[name], feature) Chris@17: ) { Chris@17: Drupal.editorConfiguration.modifiedFeature(feature); Chris@17: } Chris@17: }); Chris@17: // Update the CKEditor features metadata. Chris@17: view.model.set('featuresMetadata', features); Chris@17: }); Chris@17: }, Chris@17: ); Chris@17: }, Chris@0: Chris@17: /** Chris@17: * Returns the list of buttons from an editor configuration. Chris@17: * Chris@17: * @param {object} config Chris@17: * A CKEditor configuration object. Chris@17: * Chris@17: * @return {Array} Chris@17: * A list of buttons in the CKEditor configuration. Chris@17: */ Chris@17: getButtonList(config) { Chris@17: const buttons = []; Chris@17: // Remove the rows. Chris@17: config = _.flatten(config); Chris@0: Chris@17: // Loop through the button groups and pull out the buttons. Chris@17: config.forEach(group => { Chris@17: group.items.forEach(button => { Chris@17: buttons.push(button); Chris@0: }); Chris@0: }); Chris@17: Chris@17: // Remove the dividing elements if any. Chris@17: return _.without(buttons, '-'); Chris@17: }, Chris@0: }, Chris@17: ); Chris@17: })(jQuery, Drupal, Backbone, CKEDITOR, _);