Commit db0e9117 authored by alexpott's avatar alexpott

Issue #1894644 by Wim Leers, quicksketch: Unidirectional editor configuration...

Issue #1894644 by Wim Leers, quicksketch: Unidirectional editor configuration -> filter settings syncing.
parent 32eadec8
......@@ -47,6 +47,9 @@ function ckeditor_library_info() {
array('system', 'jquery.ui.sortable'),
array('system', 'jquery.ui.draggable'),
array('system', 'jquery.ui.touch-punch'),
array('ckeditor', 'ckeditor'),
array('editor', 'drupal.editor.admin'),
array('system', 'underscore')
),
);
$libraries['drupal.ckeditor.stylescombo.admin'] = array(
......@@ -60,6 +63,9 @@ function ckeditor_library_info() {
array('system', 'drupal'),
array('system', 'jquery.once'),
array('system', 'drupal.vertical-tabs'),
array('system', 'drupalSettings'),
// @todo D8 formUpdated event should be debounced already.
array('system', 'drupal.debounce'),
),
);
$libraries['ckeditor'] = array(
......
(function ($, Drupal, drupalSettings) {
(function ($, Drupal, drupalSettings, CKEDITOR, _) {
"use strict";
......@@ -11,6 +11,8 @@ Drupal.behaviors.ckeditorAdmin = {
attach: function (context) {
var $context = $(context);
var $ckeditorToolbar = $context.find('.ckeditor-toolbar-configuration').once('ckeditor-toolbar');
var featuresMetadata = {};
var hiddenCKEditorConfig = drupalSettings.ckeditor.hiddenCKEditorConfig;
/**
* Event callback for keypress. Move buttons based on arrow keys.
......@@ -168,6 +170,8 @@ Drupal.behaviors.ckeditorAdmin = {
* textarea.
*/
function adminToolbarValue (event, ui) {
var oldToolbarConfig = JSON.parse($textarea.val());
// Update the toolbar config after updating a sortable.
var toolbarConfig = [];
var $button = ui.item;
......@@ -184,16 +188,167 @@ Drupal.behaviors.ckeditorAdmin = {
});
$textarea.val(JSON.stringify(toolbarConfig, null, ' '));
// Determine whether we should trigger an event.
var from = $(event.target).parents('div[data-toolbar]').attr('data-toolbar');
var to = $(event.toElement).parents('div[data-toolbar]').attr('data-toolbar');
if (from !== to) {
$ckeditorToolbar.find('.ckeditor-toolbar-active')
if (!ui.silent) {
// Determine whether we should trigger an event.
var prev = _.flatten(oldToolbarConfig);
var next = _.flatten(toolbarConfig);
if (prev.length !== next.length) {
$ckeditorToolbar
.find('.ckeditor-toolbar-active')
.trigger('CKEditorToolbarChanged', [
(to === 'active') ? 'added' : 'removed',
ui.item.get(0).getAttribute('data-button-name')
(prev.length < next.length) ? 'added' : 'removed',
_.difference(_.union(prev, next), _.intersection(prev, next))[0]
]);
}
}
}
/**
* Asynchronously retrieve the metadata for all available CKEditor features.
*
* In order to get a list of all features needed by CKEditor, we create a
* hidden CKEditor instance, then check the CKEditor's "allowedContent"
* filter settings. Because creating an instance is expensive, a callback
* must be provided that will receive a hash of Drupal.EditorFeature
* features keyed by feature (button) name.
*/
function getCKEditorFeatures(CKEditorConfig, callback) {
var getProperties = function (CKEPropertiesList) {
return (_.isObject(CKEPropertiesList)) ? _.keys(CKEPropertiesList) : [];
};
var convertCKERulesToEditorFeature = function (feature, CKEFeatureRules) {
for (var i = 0; i < CKEFeatureRules.length; i++) {
var CKERule = CKEFeatureRules[i];
var rule = new Drupal.EditorFeatureHTMLRule();
// Tags.
var tags = getProperties(CKERule.elements);
rule.required.tags = (CKERule.propertiesOnly) ? [] : tags;
rule.allowed.tags = tags;
// Attributes.
rule.required.attributes = getProperties(CKERule.requiredAttributes);
rule.allowed.attributes = getProperties(CKERule.attributes);
// Styles.
rule.required.styles = getProperties(CKERule.requiredStyles);
rule.allowed.styles = getProperties(CKERule.styles);
// Classes.
rule.required.classes = getProperties(CKERule.requiredClasses);
rule.allowed.classes = getProperties(CKERule.classes);
// Raw.
rule.raw = CKERule;
feature.addHTMLRule(rule);
}
};
// Create hidden CKEditor with all features enabled, retrieve metadata.
// @see \Drupal\ckeditor\Plugin\editor\editor\CKEditor::settingsForm.
var hiddenCKEditorID = 'ckeditor-hidden';
if (CKEDITOR.instances[hiddenCKEditorID]) {
CKEDITOR.instances[hiddenCKEditorID].destroy(true);
}
CKEDITOR.inline($('#' + hiddenCKEditorID).get(0), CKEditorConfig);
// Once the instance is ready, retrieve the allowedContent filter rules
// and convert them to Drupal.EditorFeature objects.
CKEDITOR.once('instanceReady', function (e) {
if (e.editor.name === hiddenCKEditorID) {
// First collect all CKEditor allowedContent rules.
var CKEFeatureRulesMap = {};
var rules = e.editor.filter.allowedContent;
var rule, name;
for (var i = 0; i < rules.length; i++) {
rule = rules[i];
name = rule.featureName || ':(';
if (!CKEFeatureRulesMap[name]) {
CKEFeatureRulesMap[name] = [];
}
CKEFeatureRulesMap[name].push(rule);
}
// Now convert these to Drupal.EditorFeature objects.
var features = {};
for (var featureName in CKEFeatureRulesMap) {
if (CKEFeatureRulesMap.hasOwnProperty(featureName)) {
var feature = new Drupal.EditorFeature(featureName);
convertCKERulesToEditorFeature(feature, CKEFeatureRulesMap[featureName]);
features[featureName] = feature;
}
}
callback(features);
}
});
}
/**
* Retrieves the feature for a given button from featuresMetadata. Returns
* false if the given button is in fact a divider.
*/
function getFeatureForButton (button) {
// Return false if the button being added is a divider.
if (button === '|' || button === '-') {
return false;
}
// Get a Drupal.editorFeature object that contains all metadata for
// the feature that was just added or removed. Not every feature has
// such metadata.
var featureName = button.toLowerCase();
if (!featuresMetadata[featureName]) {
featuresMetadata[featureName] = new Drupal.EditorFeature(featureName);
}
return featuresMetadata[featureName];
}
/**
* Sets up broadcasting of CKEditor toolbar configuration changes.
*/
function broadcastConfigurationChanges ($ckeditorToolbar) {
$ckeditorToolbar
.find('.ckeditor-toolbar-active')
// Listen for CKEditor toolbar configuration changes. When a button is
// added/removed, call an appropriate Drupal.editorConfiguration method.
.on('CKEditorToolbarChanged.ckeditorAdmin', function (e, action, button) {
var feature = getFeatureForButton(button);
// Early-return if the button being added is a divider.
if (feature === false) {
return;
}
// Trigger a standardized text editor configuration event to indicate
// whether a feature was added or removed, so that filters can react.
var event = (action === 'added') ? 'addedFeature' : 'removedFeature';
Drupal.editorConfiguration[event](feature);
})
// Listen for CKEditor plugin settings changes. When a plugin setting is
// changed, rebuild the CKEditor features metadata.
.on('CKEditorPluginSettingsChanged.ckeditorAdmin', function (e, settingsChanges) {
// Update hidden CKEditor configuration.
for (var key in settingsChanges) {
if (settingsChanges.hasOwnProperty(key)) {
hiddenCKEditorConfig[key] = settingsChanges[key];
}
}
// Retrieve features for the updated hidden CKEditor configuration.
getCKEditorFeatures(hiddenCKEditorConfig, function (features) {
// Trigger a standardized text editor configuration event for each
// feature that was modified by the configuration changes.
for (var name in features) {
if (features.hasOwnProperty(name)) {
var feature = features[name];
if (featuresMetadata.hasOwnProperty(name) && !_.isEqual(featuresMetadata[name], feature)) {
Drupal.editorConfiguration.modifiedFeature(feature);
}
}
}
// Update the CKEditor features metadata.
featuresMetadata = features;
});
});
}
if ($ckeditorToolbar.length) {
......@@ -242,6 +397,66 @@ Drupal.behaviors.ckeditorAdmin = {
// Identify the aria-live element for interaction updates for screen
// readers.
$messages = $('#ckeditor-button-configuration-aria-live');
getCKEditorFeatures(hiddenCKEditorConfig, function (features) {
featuresMetadata = features;
// Ensure that toolbar configuration changes are broadcast.
broadcastConfigurationChanges($ckeditorToolbar);
// Initialization: not all of the default toolbar buttons may be allowed
// by the current filter settings. Remove any of the default toolbar
// buttons that require more permissive filter settings. The remaining
// default toolbar buttons are marked as "added".
var $activeToolbar = $ckeditorToolbar.find('.ckeditor-toolbar-active');
var existingButtons = _.unique(_.flatten(JSON.parse($textarea.val())));
for (var i = 0; i < existingButtons.length; i++) {
var button = existingButtons[i];
var feature = getFeatureForButton(button);
// Skip dividers.
if (feature === false) {
continue;
}
if (Drupal.editorConfiguration.featureIsAllowedByFilters(feature)) {
// Default toolbar buttons are in fact "added features".
$activeToolbar.trigger('CKEditorToolbarChanged', ['added', existingButtons[i]]);
}
else {
// Move the button element from the active the active toolbar to the
// list of available buttons.
var $button = $('.ckeditor-toolbar-active > ul > li[data-button-name="' + button + '"]')
.detach()
.appendTo('.ckeditor-toolbar-disabled > ul');
// Update the toolbar value field.
adminToolbarValue({}, { silent: true, item: $button});
}
}
});
}
},
detach: function (context, settings, trigger) {
// Early-return if the trigger for detachment is something else than unload.
if (trigger !== 'unload') {
return;
}
// We're detaching because CKEditor as text editor has been disabled; this
// really means that all CKEditor toolbar buttons have been removed. Hence,
// all editor features will be removed, so any reactions from filters will
// be undone.
var $ckeditorToolbar = $(context).find('.ckeditor-toolbar-configuration.ckeditor-toolbar-processed');
if ($ckeditorToolbar.length) {
var value = $ckeditorToolbar
.find('.form-item-editor-settings-toolbar-buttons')
.find('textarea')
.val();
var $activeToolbar = $ckeditorToolbar.find('.ckeditor-toolbar-active');
var buttons = _.unique(_.flatten(JSON.parse(value)));
for (var i = 0; i < buttons.length; i++) {
$activeToolbar.trigger('CKEditorToolbarChanged', ['removed', buttons[i]]);
}
}
}
};
......@@ -292,4 +507,4 @@ function grantRowFocus (event) {
}
}
})(jQuery, Drupal, drupalSettings);
})(jQuery, Drupal, drupalSettings, CKEDITOR, _);
......@@ -87,7 +87,7 @@ Drupal.editors.ckeditor = {
return !!CKEDITOR.inline(element, settings);
},
_loadExternalPlugins: function(format) {
_loadExternalPlugins: function (format) {
var externalPlugins = format.editorSettings.drupalExternalPlugins;
// Register and load additional CKEditor plugins as necessary.
if (externalPlugins) {
......
(function ($, Drupal) {
(function ($, Drupal, drupalSettings) {
"use strict";
/**
* Shows the "stylescombo" plugin settings only when the button is enabled.
*/
Drupal.behaviors.ckeditorStylesComboSettingsVisibility = {
Drupal.behaviors.ckeditorStylesComboSettings = {
attach: function (context) {
var $context = $(context);
var $stylesComboVerticalTab = $('#edit-editor-settings-plugins-stylescombo').data('verticalTab');
// Hide if the "Styles" button is disabled.
......@@ -15,9 +16,9 @@ Drupal.behaviors.ckeditorStylesComboSettingsVisibility = {
}
// React to added/removed toolbar buttons.
$(context)
$context
.find('.ckeditor-toolbar-active')
.on('CKEditorToolbarChanged', function (e, action, button) {
.on('CKEditorToolbarChanged.ckeditorStylesComboSettings', function (e, action, button) {
if (button === 'Styles') {
if (action === 'added') {
$stylesComboVerticalTab.tabShow();
......@@ -27,6 +28,80 @@ Drupal.behaviors.ckeditorStylesComboSettingsVisibility = {
}
}
});
// React to changes in the list of user-defined styles: calculate the new
// stylesSet setting up to 2 times per second, and if it is different, fire
// the CKEditorPluginSettingsChanged event with the updated parts of the
// CKEditor configuration. (This will, in turn, cause the hidden CKEditor
// instance to be updated and a drupalEditorFeatureModified event to fire.)
var $ckeditorActiveToolbar = $context
.find('.ckeditor-toolbar-configuration')
.find('.ckeditor-toolbar-active');
var previousStylesSet = drupalSettings.ckeditor.hiddenCKEditorConfig.stylesSet;
var that = this;
$context.find('[name="editor[settings][plugins][stylescombo][styles]"]')
.on('blur.ckeditorStylesComboSettings', function () {
var styles = $.trim($('#edit-editor-settings-plugins-stylescombo-styles').val());
var stylesSet = that._generateStylesSetSetting(styles);
if (!_.isEqual(previousStylesSet, stylesSet)) {
previousStylesSet = stylesSet;
$ckeditorActiveToolbar.trigger('CKEditorPluginSettingsChanged', [{ stylesSet: stylesSet } ]);
}
});
},
/**
* Builds the "stylesSet" configuration part of the CKEditor JS settings.
*
* @see Drupal\ckeditor\Plugin\ckeditor\plugin\StylesCombo::generateStylesSetSetting()
*
* Note that this is a more forgiving implementation than the PHP version: the
* parsing works identically, but instead of failing on invalid styles, we
* just ignore those.
*
* @param String sstyles
* The "styles" setting.
*
* @return array
* An array containing the "stylesSet" configuration.
*/
_generateStylesSetSetting: function (styles) {
var stylesSet = [];
styles = styles.replace(/\r/g, "\n");
var lines = styles.split("\n");
for (var i = 0; i < lines.length; i++) {
var style = $.trim(lines[i]);
// Ignore empty lines in between non-empty lines.
if (style.length === 0) {
continue;
}
// Validate syntax: element[.class...]|label pattern expected.
if (style.match(/^ *[a-zA-Z0-9]+ *(\.[a-zA-Z0-9_-]+ *)*\| *.+ *$/) === null) {
// Instead of failing, we just ignore any invalid styles.
continue;
}
// Parse.
var parts = style.split('|');
var selector = parts[0];
var label = parts[1];
var classes = selector.split('.');
var element = classes.shift();
// Build the data structure CKEditor's stylescombo plugin expects.
// @see http://docs.cksource.com/CKEditor_3.x/Developers_Guide/Styles
stylesSet.push({
attributes: { class: classes.join(' ') },
element: element,
name: label
});
}
return stylesSet;
}
};
......@@ -48,4 +123,4 @@ Drupal.behaviors.ckeditorStylesComboSettingsSummary = {
}
};
})(jQuery, Drupal);
})(jQuery, Drupal, drupalSettings);
......@@ -96,8 +96,6 @@ public function getEnabledPlugins(Editor $editor, $include_internal_plugins = FA
/**
* Retrieves all plugins that implement CKEditorPluginButtonsInterface.
*
* @param \Drupal\editor\Plugin\Core\Entity\Editor $editor
* A configured text editor object.
* @return array
* A list of the CKEditor plugins that implement buttons, with the plugin
* IDs as keys and lists of button metadata (as implemented by getButtons())
......@@ -105,7 +103,7 @@ public function getEnabledPlugins(Editor $editor, $include_internal_plugins = FA
*
* @see CKEditorPluginButtonsInterface::getButtons()
*/
public function getButtonsPlugins(Editor $editor) {
public function getButtonsPlugins() {
$plugins = array_keys($this->getDefinitions());
$buttons_plugins = array();
......
......@@ -44,6 +44,9 @@ public function getFile() {
*/
public function getConfig(Editor $editor) {
$config = array();
if (!isset($editor->settings['plugins']['stylescombo']['styles'])) {
return $config;
}
$styles = $editor->settings['plugins']['stylescombo']['styles'];
$config['stylesSet'] = $this->generateStylesSetSetting($styles);
return $config;
......@@ -126,8 +129,8 @@ protected function generateStylesSetSetting($styles) {
continue;
}
// Validate syntax: element.class[.class...]|label pattern expected.
if (!preg_match('@^ *[a-zA-Z0-9]+ *(\\.[a-zA-Z0-9_-]+ *)+\\| *.+ *$@', $style)) {
// Validate syntax: element[.class...]|label pattern expected.
if (!preg_match('@^ *[a-zA-Z0-9]+ *(\\.[a-zA-Z0-9_-]+ *)*\\| *.+ *$@', $style)) {
return FALSE;
}
......@@ -138,13 +141,16 @@ protected function generateStylesSetSetting($styles) {
// Build the data structure CKEditor's stylescombo plugin expects.
// @see http://docs.cksource.com/CKEditor_3.x/Developers_Guide/Styles
$styles_set[] = array(
$configured_style = array(
'name' => trim($label),
'element' => trim($element),
'attributes' => array(
'class' => implode(' ', array_map('trim', $classes))
),
);
if (!empty($classes)) {
$configured_style['attributes'] = array(
'class' => implode(' ', array_map('trim', $classes))
);
}
$styles_set[] = $configured_style;
}
return $styles_set;
}
......
......@@ -53,7 +53,7 @@ public function settingsForm(array $form, array &$form_state, EditorEntity $edit
$ckeditor_settings_toolbar = array(
'#theme' => 'ckeditor_settings_toolbar',
'#editor' => $editor,
'#plugins' => $manager->getButtonsPlugins($editor),
'#plugins' => $manager->getButtonsPlugins(),
);
$form['toolbar'] = array(
'#type' => 'container',
......@@ -87,6 +87,49 @@ public function settingsForm(array $form, array &$form_state, EditorEntity $edit
unset($form['plugin_settings']);
}
// Hidden CKEditor instance. We need a hidden CKEditor instance with all
// plugins enabled, so we can retrieve CKEditor's per-feature metadata (on
// which tags, attributes, styles and classes are enabled). This metadata is
// necessary for certain filters' (e.g. the html_filter filter) settings to
// be updated accordingly.
// Get a list of all external plugins and their corresponding files.
$plugins = array_keys($manager->getDefinitions());
$all_external_plugins = array();
foreach ($plugins as $plugin_id) {
$plugin = $manager->createInstance($plugin_id);
if (!$plugin->isInternal()) {
$all_external_plugins[$plugin_id] = $plugin->getFile();
}
}
// Get a list of all buttons that are provided by all plugins.
$all_buttons = array_reduce($manager->getButtonsPlugins(), function($result, $item) {
return array_merge($result, array_keys($item));
}, array());
// Build a fake Editor object, which we'll use to generate JavaScript
// settings for this fake Editor instance.
$fake_editor = entity_create('editor', array(
'format' => '',
'editor' => 'ckeditor',
'settings' => array(
// Single toolbar row that contains all existing buttons.
'toolbar' => array('buttons' => array(0 => $all_buttons)),
'plugins' => $editor->settings['plugins'],
),
));
$form['hidden_ckeditor'] = array(
'#markup' => '<div id="ckeditor-hidden" class="element-hidden"></div>',
'#attached' => array(
'js' => array(
array(
'type' => 'setting',
'data' => array('ckeditor' => array(
'hiddenCKEditorConfig' => $this->getJSSettings($fake_editor),
)),
),
),
),
);
return $form;
}
......
......@@ -213,8 +213,14 @@ function testStylesComboGetConfig() {
$editor->save();
$this->assertIdentical($expected, $stylescombo_plugin->getConfig($editor), '"StylesCombo" plugin configuration built correctly for customized toolbar.');
// Slightly different configuration: class names are optional.
$editor->settings['plugins']['stylescombo']['styles'] = " h1 | Title ";
$editor->save();
$expected['stylesSet'] = array(array('name' => 'Title', 'element' => 'h1'));
$this->assertIdentical($expected, $stylescombo_plugin->getConfig($editor), '"StylesCombo" plugin configuration built correctly for customized toolbar.');
// Invalid syntax should cause stylesSet to be set to FALSE.
$editor->settings['plugins']['stylescombo']['styles'] = "h1|Title";
$editor->settings['plugins']['stylescombo']['styles'] = "h1";
$editor->save();
$expected['stylesSet'] = FALSE;
$this->assertIdentical($expected, $stylescombo_plugin->getConfig($editor), '"StylesCombo" plugin configuration built correctly for customized toolbar.');
......
......@@ -65,6 +65,18 @@ function editor_element_info() {
*/
function editor_library_info() {
$path = drupal_get_path('module', 'editor');
$libraries['drupal.editor.admin'] = array(
'title' => 'Text Editor',
'version' => VERSION,
'js' => array(
$path . '/js/editor.admin.js' => array(),
),
'dependencies' => array(
array('system', 'jquery'),
array('system', 'drupal'),
),
);
$libraries['drupal.editor'] = array(
'title' => 'Text Editor',
'version' => VERSION,
......@@ -200,6 +212,11 @@ function editor_form_filter_admin_format_form_alter(&$form, &$form_state) {
'#weight' => -8,
'#type' => 'container',
'#id' => 'editor-settings-wrapper',
'#attached' => array(
'library' => array(
array('editor', 'drupal.editor.admin'),
),
),
);
// Add editor-specific validation and submit handlers.
......
/**
* @file
* Provides a JavaScript API to broadcast text editor configuration changes.
*
* Filter implementations may listen to the drupalEditorFeatureAdded,
* drupalEditorFeatureRemoved, and drupalEditorFeatureRemoved events on document
* to automatically adjust their settings based on the editor configuration.
*/
(function ($, _, Drupal, document) {
"use strict";
Drupal.editorConfiguration = {
/**
* Must be called by a specific text editor's configuration whenever a feature
* is added by the user.
*
* Triggers the drupalEditorFeatureAdded event on the document, which receives
* a Drupal.EditorFeature object.
*
* @param Drupal.EditorFeature feature
* A text editor feature object.
*/
addedFeature: function (feature) {
$(document).trigger('drupalEditorFeatureAdded', feature);
},
/**
* Must be called by a specific text editor's configuration whenever a feature
* is removed by the user.
*
* Triggers the drupalEditorFeatureRemoved event on the document, which
* receives a Drupal.EditorFeature object.
*
* @param Drupal.EditorFeature feature
* A text editor feature object.