Move into nested docroot

This commit is contained in:
Rob Davies 2017-02-13 15:31:17 +00:00
parent 83a0d3a149
commit c8b70abde9
13405 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,499 @@
/**
* @file
* CKEditor button and group configuration user interface.
*/
(function ($, Drupal, drupalSettings, _) {
'use strict';
Drupal.ckeditor = Drupal.ckeditor || {};
/**
* Sets config behaviour and creates config views for the CKEditor toolbar.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches admin behaviour to the CKEditor buttons.
* @prop {Drupal~behaviorDetach} detach
* Detaches admin behaviour from the CKEditor buttons on 'unload'.
*/
Drupal.behaviors.ckeditorAdmin = {
attach: function (context) {
// Process the CKEditor configuration fragment once.
var $configurationForm = $(context).find('.ckeditor-toolbar-configuration').once('ckeditor-configuration');
if ($configurationForm.length) {
var $textarea = $configurationForm
// Hide the textarea that contains the serialized representation of the
// CKEditor configuration.
.find('.js-form-item-editor-settings-toolbar-button-groups')
.hide()
// Return the textarea child node from this expression.
.find('textarea');
// The HTML for the CKEditor configuration is assembled on the server
// and sent to the client as a serialized DOM fragment.
$configurationForm.append(drupalSettings.ckeditor.toolbarAdmin);
// Create a configuration model.
var model = Drupal.ckeditor.models.Model = new Drupal.ckeditor.Model({
$textarea: $textarea,
activeEditorConfig: JSON.parse($textarea.val()),
hiddenEditorConfig: drupalSettings.ckeditor.hiddenCKEditorConfig
});
// Create the configuration Views.
var viewDefaults = {
model: model,
el: $('.ckeditor-toolbar-configuration')
};
Drupal.ckeditor.views = {
controller: new Drupal.ckeditor.ControllerView(viewDefaults),
visualView: new Drupal.ckeditor.VisualView(viewDefaults),
keyboardView: new Drupal.ckeditor.KeyboardView(viewDefaults),
auralView: new Drupal.ckeditor.AuralView(viewDefaults)
};
}
},
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 $configurationForm = $(context).find('.ckeditor-toolbar-configuration').findOnce('ckeditor-configuration');
if ($configurationForm.length && Drupal.ckeditor.models && Drupal.ckeditor.models.Model) {
var config = Drupal.ckeditor.models.Model.toJSON().activeEditorConfig;
var buttons = Drupal.ckeditor.views.controller.getButtonList(config);
var $activeToolbar = $('.ckeditor-toolbar-configuration').find('.ckeditor-toolbar-active');
for (var i = 0; i < buttons.length; i++) {
$activeToolbar.trigger('CKEditorToolbarChanged', ['removed', buttons[i]]);
}
}
}
};
/**
* CKEditor configuration UI methods of Backbone objects.
*
* @namespace
*/
Drupal.ckeditor = {
/**
* A hash of View instances.
*
* @type {object}
*/
views: {},
/**
* A hash of Model instances.
*
* @type {object}
*/
models: {},
/**
* Translates changes in CKEditor config DOM structure to the config model.
*
* If the button is moved within an existing group, the DOM structure is
* simply translated to a configuration model. If the button is moved into a
* new group placeholder, then a process is launched to name that group
* before the button move is translated into configuration.
*
* @param {Backbone.View} view
* The Backbone View that invoked this function.
* @param {jQuery} $button
* A jQuery set that contains an li element that wraps a button element.
* @param {function} callback
* A callback to invoke after the button group naming modal dialog has
* been closed.
*
*/
registerButtonMove: function (view, $button, callback) {
var $group = $button.closest('.ckeditor-toolbar-group');
// If dropped in a placeholder button group, the user must name it.
if ($group.hasClass('placeholder')) {
if (view.isProcessing) {
return;
}
view.isProcessing = true;
Drupal.ckeditor.openGroupNameDialog(view, $group, callback);
}
else {
view.model.set('isDirty', true);
callback(true);
}
},
/**
* Translates changes in CKEditor config DOM structure to the config model.
*
* Each row has a placeholder group at the end of the row. A user may not
* move an existing button group past the placeholder group at the end of a
* row.
*
* @param {Backbone.View} view
* The Backbone View that invoked this function.
* @param {jQuery} $group
* A jQuery set that contains an li element that wraps a group of buttons.
*/
registerGroupMove: function (view, $group) {
// Remove placeholder classes if necessary.
var $row = $group.closest('.ckeditor-row');
if ($row.hasClass('placeholder')) {
$row.removeClass('placeholder');
}
// If there are any rows with just a placeholder group, mark the row as a
// placeholder.
$row.parent().children().each(function () {
$row = $(this);
if ($row.find('.ckeditor-toolbar-group').not('.placeholder').length === 0) {
$row.addClass('placeholder');
}
});
view.model.set('isDirty', true);
},
/**
* Opens a dialog with a form for changing the title of a button group.
*
* @param {Backbone.View} view
* The Backbone View that invoked this function.
* @param {jQuery} $group
* A jQuery set that contains an li element that wraps a group of buttons.
* @param {function} callback
* A callback to invoke after the button group naming modal dialog has
* been closed.
*/
openGroupNameDialog: function (view, $group, callback) {
callback = callback || function () {};
/**
* Validates the string provided as a button group title.
*
* @param {HTMLElement} form
* The form DOM element that contains the input with the new button
* group title string.
*
* @return {bool}
* Returns true when an error exists, otherwise returns false.
*/
function validateForm(form) {
if (form.elements[0].value.length === 0) {
var $form = $(form);
if (!$form.hasClass('errors')) {
$form
.addClass('errors')
.find('input')
.addClass('error')
.attr('aria-invalid', 'true');
$('<div class=\"description\" >' + Drupal.t('Please provide a name for the button group.') + '</div>').insertAfter(form.elements[0]);
}
return true;
}
return false;
}
/**
* Attempts to close the dialog; Validates user input.
*
* @param {string} action
* The dialog action chosen by the user: 'apply' or 'cancel'.
* @param {HTMLElement} form
* The form DOM element that contains the input with the new button
* group title string.
*/
function closeDialog(action, form) {
/**
* Closes the dialog when the user cancels or supplies valid data.
*/
function shutdown() {
dialog.close(action);
// The processing marker can be deleted since the dialog has been
// closed.
delete view.isProcessing;
}
/**
* Applies a string as the name of a CKEditor button group.
*
* @param {jQuery} $group
* A jQuery set that contains an li element that wraps a group of
* buttons.
* @param {string} name
* The new name of the CKEditor button group.
*/
function namePlaceholderGroup($group, name) {
// If it's currently still a placeholder, then that means we're
// creating a new group, and we must do some extra work.
if ($group.hasClass('placeholder')) {
// Remove all whitespace from the name, lowercase it and ensure
// HTML-safe encoding, then use this as the group ID for CKEditor
// configuration UI accessibility purposes only.
var groupID = 'ckeditor-toolbar-group-aria-label-for-' + Drupal.checkPlain(name.toLowerCase().replace(/\s/g, '-'));
$group
// Update the group container.
.removeAttr('aria-label')
.attr('data-drupal-ckeditor-type', 'group')
.attr('tabindex', 0)
// Update the group heading.
.children('.ckeditor-toolbar-group-name')
.attr('id', groupID)
.end()
// Update the group items.
.children('.ckeditor-toolbar-group-buttons')
.attr('aria-labelledby', groupID);
}
$group
.attr('data-drupal-ckeditor-toolbar-group-name', name)
.children('.ckeditor-toolbar-group-name')
.text(name);
}
// Invoke a user-provided callback and indicate failure.
if (action === 'cancel') {
shutdown();
callback(false, $group);
return;
}
// Validate that a group name was provided.
if (form && validateForm(form)) {
return;
}
// React to application of a valid group name.
if (action === 'apply') {
shutdown();
// Apply the provided name to the button group label.
namePlaceholderGroup($group, Drupal.checkPlain(form.elements[0].value));
// Remove placeholder classes so that new placeholders will be
// inserted.
$group.closest('.ckeditor-row.placeholder').addBack().removeClass('placeholder');
// Invoke a user-provided callback and indicate success.
callback(true, $group);
// Signal that the active toolbar DOM structure has changed.
view.model.set('isDirty', true);
}
}
// Create a Drupal dialog that will get a button group name from the user.
var $ckeditorButtonGroupNameForm = $(Drupal.theme('ckeditorButtonGroupNameForm'));
var dialog = Drupal.dialog($ckeditorButtonGroupNameForm.get(0), {
title: Drupal.t('Button group name'),
dialogClass: 'ckeditor-name-toolbar-group',
resizable: false,
buttons: [
{
text: Drupal.t('Apply'),
click: function () {
closeDialog('apply', this);
},
primary: true
},
{
text: Drupal.t('Cancel'),
click: function () {
closeDialog('cancel');
}
}
],
open: function () {
var form = this;
var $form = $(this);
var $widget = $form.parent();
$widget.find('.ui-dialog-titlebar-close').remove();
// Set a click handler on the input and button in the form.
$widget.on('keypress.ckeditor', 'input, button', function (event) {
// React to enter key press.
if (event.keyCode === 13) {
var $target = $(event.currentTarget);
var data = $target.data('ui-button');
var action = 'apply';
// Assume 'apply', but take into account that the user might have
// pressed the enter key on the dialog buttons.
if (data && data.options && data.options.label) {
action = data.options.label.toLowerCase();
}
closeDialog(action, form);
event.stopPropagation();
event.stopImmediatePropagation();
event.preventDefault();
}
});
// Announce to the user that a modal dialog is open.
var text = Drupal.t('Editing the name of the new button group in a dialog.');
if (typeof $group.attr('data-drupal-ckeditor-toolbar-group-name') !== 'undefined') {
text = Drupal.t('Editing the name of the "@groupName" button group in a dialog.', {
'@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name')
});
}
Drupal.announce(text);
},
close: function (event) {
// Automatically destroy the DOM element that was used for the dialog.
$(event.target).remove();
}
});
// A modal dialog is used because the user must provide a button group
// name or cancel the button placement before taking any other action.
dialog.showModal();
$(document.querySelector('.ckeditor-name-toolbar-group').querySelector('input'))
// When editing, set the "group name" input in the form to the current
// value.
.attr('value', $group.attr('data-drupal-ckeditor-toolbar-group-name'))
// Focus on the "group name" input in the form.
.trigger('focus');
}
};
/**
* Automatically shows/hides settings of buttons-only CKEditor plugins.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches show/hide behaviour to Plugin Settings buttons.
*/
Drupal.behaviors.ckeditorAdminButtonPluginSettings = {
attach: function (context) {
var $context = $(context);
var $ckeditorPluginSettings = $context.find('#ckeditor-plugin-settings').once('ckeditor-plugin-settings');
if ($ckeditorPluginSettings.length) {
// Hide all button-dependent plugin settings initially.
$ckeditorPluginSettings.find('[data-ckeditor-buttons]').each(function () {
var $this = $(this);
if ($this.data('verticalTab')) {
$this.data('verticalTab').tabHide();
}
else {
// On very narrow viewports, Vertical Tabs are disabled.
$this.hide();
}
$this.data('ckeditorButtonPluginSettingsActiveButtons', []);
});
// Whenever a button is added or removed, check if we should show or
// hide the corresponding plugin settings. (Note that upon
// initialization, each button that already is part of the toolbar still
// is considered "added", hence it also works correctly for buttons that
// were added previously.)
$context
.find('.ckeditor-toolbar-active')
.off('CKEditorToolbarChanged.ckeditorAdminPluginSettings')
.on('CKEditorToolbarChanged.ckeditorAdminPluginSettings', function (event, action, button) {
var $pluginSettings = $ckeditorPluginSettings
.find('[data-ckeditor-buttons~=' + button + ']');
// No settings for this button.
if ($pluginSettings.length === 0) {
return;
}
var verticalTab = $pluginSettings.data('verticalTab');
var activeButtons = $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons');
if (action === 'added') {
activeButtons.push(button);
// Show this plugin's settings if >=1 of its buttons are active.
if (verticalTab) {
verticalTab.tabShow();
}
else {
// On very narrow viewports, Vertical Tabs remain fieldsets.
$pluginSettings.show();
}
}
else {
// Remove this button from the list of active buttons.
activeButtons.splice(activeButtons.indexOf(button), 1);
// Show this plugin's settings 0 of its buttons are active.
if (activeButtons.length === 0) {
if (verticalTab) {
verticalTab.tabHide();
}
else {
// On very narrow viewports, Vertical Tabs are disabled.
$pluginSettings.hide();
}
}
}
$pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons', activeButtons);
});
}
}
};
/**
* Themes a blank CKEditor row.
*
* @return {string}
* A HTML string for a CKEditor row.
*/
Drupal.theme.ckeditorRow = function () {
return '<li class="ckeditor-row placeholder" role="group"><ul class="ckeditor-toolbar-groups clearfix"></ul></li>';
};
/**
* Themes a blank CKEditor button group.
*
* @return {string}
* A HTML string for a CKEditor button group.
*/
Drupal.theme.ckeditorToolbarGroup = function () {
var group = '';
group += '<li class="ckeditor-toolbar-group placeholder" role="presentation" aria-label="' + Drupal.t('Place a button to create a new button group.') + '">';
group += '<h3 class="ckeditor-toolbar-group-name">' + Drupal.t('New group') + '</h3>';
group += '<ul class="ckeditor-buttons ckeditor-toolbar-group-buttons" role="toolbar" data-drupal-ckeditor-button-sorting="target"></ul>';
group += '</li>';
return group;
};
/**
* Themes a form for changing the title of a CKEditor button group.
*
* @return {string}
* A HTML string for the form for the title of a CKEditor button group.
*/
Drupal.theme.ckeditorButtonGroupNameForm = function () {
return '<form><input name="group-name" required="required"></form>';
};
/**
* Themes a button that will toggle the button group names in active config.
*
* @return {string}
* A HTML string for the button to toggle group names.
*/
Drupal.theme.ckeditorButtonGroupNamesToggle = function () {
return '<button class="link ckeditor-groupnames-toggle" aria-pressed="false"></button>';
};
/**
* Themes a button that will prompt the user to name a new button group.
*
* @return {string}
* A HTML string for the button to create a name for a new button group.
*/
Drupal.theme.ckeditorNewButtonGroup = function () {
return '<li class="ckeditor-add-new-group"><button aria-label="' + Drupal.t('Add a CKEditor button group to the end of this row.') + '">' + Drupal.t('Add group') + '</button></li>';
};
})(jQuery, Drupal, drupalSettings, _);

View file

@ -0,0 +1,45 @@
/**
* @file
* CKEditor 'drupalimage' plugin admin behavior.
*/
(function ($, Drupal, drupalSettings) {
'use strict';
/**
* Provides the summary for the "drupalimage" plugin settings vertical tab.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches summary behaviour to the "drupalimage" settings vertical tab.
*/
Drupal.behaviors.ckeditorDrupalImageSettingsSummary = {
attach: function () {
$('[data-ckeditor-plugin-id="drupalimage"]').drupalSetSummary(function (context) {
var root = 'input[name="editor[settings][plugins][drupalimage][image_upload]';
var $status = $(root + '[status]"]');
var $maxFileSize = $(root + '[max_size]"]');
var $maxWidth = $(root + '[max_dimensions][width]"]');
var $maxHeight = $(root + '[max_dimensions][height]"]');
var $scheme = $(root + '[scheme]"]:checked');
var maxFileSize = $maxFileSize.val() ? $maxFileSize.val() : $maxFileSize.attr('placeholder');
var maxDimensions = ($maxWidth.val() && $maxHeight.val()) ? '(' + $maxWidth.val() + 'x' + $maxHeight.val() + ')' : '';
if (!$status.is(':checked')) {
return Drupal.t('Uploads disabled');
}
var output = '';
output += Drupal.t('Uploads enabled, max size: @size @dimensions', {'@size': maxFileSize, '@dimensions': maxDimensions});
if ($scheme.length) {
output += '<br />' + $scheme.attr('data-label');
}
return output;
});
}
};
})(jQuery, Drupal, drupalSettings);

293
web/core/modules/ckeditor/js/ckeditor.js vendored Normal file
View file

@ -0,0 +1,293 @@
/**
* @file
* CKEditor implementation of {@link Drupal.editors} API.
*/
(function (Drupal, debounce, CKEDITOR, $) {
'use strict';
/**
* @namespace
*/
Drupal.editors.ckeditor = {
/**
* Editor attach callback.
*
* @param {HTMLElement} element
* The element to attach the editor to.
* @param {string} format
* The text format for the editor.
*
* @return {bool}
* Whether the call to `CKEDITOR.replace()` created an editor or not.
*/
attach: function (element, format) {
this._loadExternalPlugins(format);
// Also pass settings that are Drupal-specific.
format.editorSettings.drupal = {
format: format.format
};
// Set a title on the CKEditor instance that includes the text field's
// label so that screen readers say something that is understandable
// for end users.
var label = $('label[for=' + element.getAttribute('id') + ']').html();
format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', {'!label': label});
return !!CKEDITOR.replace(element, format.editorSettings);
},
/**
* Editor detach callback.
*
* @param {HTMLElement} element
* The element to detach the editor from.
* @param {string} format
* The text format used for the editor.
* @param {string} trigger
* The event trigger for the detach.
*
* @return {bool}
* Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
* found an editor or not.
*/
detach: function (element, format, trigger) {
var editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
if (trigger === 'serialize') {
editor.updateElement();
}
else {
editor.destroy();
element.removeAttribute('contentEditable');
}
}
return !!editor;
},
/**
* Reacts on a change in the editor element.
*
* @param {HTMLElement} element
* The element where the change occured.
* @param {function} callback
* Callback called with the value of the editor.
*
* @return {bool}
* Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
* found an editor or not.
*/
onChange: function (element, callback) {
var editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
editor.on('change', debounce(function () {
callback(editor.getData());
}, 400));
}
return !!editor;
},
/**
* Attaches an inline editor to a DOM element.
*
* @param {HTMLElement} element
* The element to attach the editor to.
* @param {object} format
* The text format used in the editor.
* @param {string} [mainToolbarId]
* The id attribute for the main editor toolbar, if any.
* @param {string} [floatedToolbarId]
* The id attribute for the floated editor toolbar, if any.
*
* @return {bool}
* Whether the call to `CKEDITOR.replace()` created an editor or not.
*/
attachInlineEditor: function (element, format, mainToolbarId, floatedToolbarId) {
this._loadExternalPlugins(format);
// Also pass settings that are Drupal-specific.
format.editorSettings.drupal = {
format: format.format
};
var settings = $.extend(true, {}, format.editorSettings);
// If a toolbar is already provided for "true WYSIWYG" (in-place editing),
// then use that toolbar instead: override the default settings to render
// CKEditor UI's top toolbar into mainToolbar, and don't render the bottom
// toolbar at all. (CKEditor doesn't need a floated toolbar.)
if (mainToolbarId) {
var settingsOverride = {
extraPlugins: 'sharedspace',
removePlugins: 'floatingspace,elementspath',
sharedSpaces: {
top: mainToolbarId
}
};
// Find the "Source" button, if any, and replace it with "Sourcedialog".
// (The 'sourcearea' plugin only works in CKEditor's iframe mode.)
var sourceButtonFound = false;
for (var i = 0; !sourceButtonFound && i < settings.toolbar.length; i++) {
if (settings.toolbar[i] !== '/') {
for (var j = 0; !sourceButtonFound && j < settings.toolbar[i].items.length; j++) {
if (settings.toolbar[i].items[j] === 'Source') {
sourceButtonFound = true;
// Swap sourcearea's "Source" button for sourcedialog's.
settings.toolbar[i].items[j] = 'Sourcedialog';
settingsOverride.extraPlugins += ',sourcedialog';
settingsOverride.removePlugins += ',sourcearea';
}
}
}
}
settings.extraPlugins += ',' + settingsOverride.extraPlugins;
settings.removePlugins += ',' + settingsOverride.removePlugins;
settings.sharedSpaces = settingsOverride.sharedSpaces;
}
// CKEditor requires an element to already have the contentEditable
// attribute set to "true", otherwise it won't attach an inline editor.
element.setAttribute('contentEditable', 'true');
return !!CKEDITOR.inline(element, settings);
},
/**
* Loads the required external plugins for the editor.
*
* @param {object} format
* The text format used in the editor.
*/
_loadExternalPlugins: function (format) {
var externalPlugins = format.editorSettings.drupalExternalPlugins;
// Register and load additional CKEditor plugins as necessary.
if (externalPlugins) {
for (var pluginName in externalPlugins) {
if (externalPlugins.hasOwnProperty(pluginName)) {
CKEDITOR.plugins.addExternal(pluginName, externalPlugins[pluginName], '');
}
}
delete format.editorSettings.drupalExternalPlugins;
}
}
};
Drupal.ckeditor = {
/**
* Variable storing the current dialog's save callback.
*
* @type {?function}
*/
saveCallback: null,
/**
* Open a dialog for a Drupal-based plugin.
*
* This dynamically loads jQuery UI (if necessary) using the Drupal AJAX
* framework, then opens a dialog at the specified Drupal path.
*
* @param {CKEditor} editor
* The CKEditor instance that is opening the dialog.
* @param {string} url
* The URL that contains the contents of the dialog.
* @param {object} existingValues
* Existing values that will be sent via POST to the url for the dialog
* contents.
* @param {function} saveCallback
* A function to be called upon saving the dialog.
* @param {object} dialogSettings
* An object containing settings to be passed to the jQuery UI.
*/
openDialog: function (editor, url, existingValues, saveCallback, dialogSettings) {
// Locate a suitable place to display our loading indicator.
var $target = $(editor.container.$);
if (editor.elementMode === CKEDITOR.ELEMENT_MODE_REPLACE) {
$target = $target.find('.cke_contents');
}
// Remove any previous loading indicator.
$target.css('position', 'relative').find('.ckeditor-dialog-loading').remove();
// Add a consistent dialog class.
var classes = dialogSettings.dialogClass ? dialogSettings.dialogClass.split(' ') : [];
classes.push('ui-dialog--narrow');
dialogSettings.dialogClass = classes.join(' ');
dialogSettings.autoResize = window.matchMedia('(min-width: 600px)').matches;
dialogSettings.width = 'auto';
// Add a "Loading…" message, hide it underneath the CKEditor toolbar,
// create a Drupal.Ajax instance to load the dialog and trigger it.
var $content = $('<div class="ckeditor-dialog-loading"><span style="top: -40px;" class="ckeditor-dialog-loading-link">' + Drupal.t('Loading...') + '</span></div>');
$content.appendTo($target);
var ckeditorAjaxDialog = Drupal.ajax({
dialog: dialogSettings,
dialogType: 'modal',
selector: '.ckeditor-dialog-loading-link',
url: url,
progress: {type: 'throbber'},
submit: {
editor_object: existingValues
}
});
ckeditorAjaxDialog.execute();
// After a short delay, show "Loading…" message.
window.setTimeout(function () {
$content.find('span').animate({top: '0px'});
}, 1000);
// Store the save callback to be executed when this dialog is closed.
Drupal.ckeditor.saveCallback = saveCallback;
}
};
// Moves the dialog to the top of the CKEDITOR stack.
$(window).on('dialogcreate', function (e, dialog, $element, settings) {
$('.ui-dialog--narrow').css('zIndex', CKEDITOR.config.baseFloatZIndex + 1);
});
// Respond to new dialogs that are opened by CKEditor, closing the AJAX loader.
$(window).on('dialog:beforecreate', function (e, dialog, $element, settings) {
$('.ckeditor-dialog-loading').animate({top: '-40px'}, function () {
$(this).remove();
});
});
// Respond to dialogs that are saved, sending data back to CKEditor.
$(window).on('editor:dialogsave', function (e, values) {
if (Drupal.ckeditor.saveCallback) {
Drupal.ckeditor.saveCallback(values);
}
});
// Respond to dialogs that are closed, removing the current save handler.
$(window).on('dialog:afterclose', function (e, dialog, $element) {
if (Drupal.ckeditor.saveCallback) {
Drupal.ckeditor.saveCallback = null;
}
});
// Redirect on hash change when the original hash has an associated CKEditor.
function redirectTextareaFragmentToCKEditorInstance() {
var hash = location.hash.substr(1);
var element = document.getElementById(hash);
if (element) {
var editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
var id = editor.container.getAttribute('id');
location.replace('#' + id);
}
}
}
$(window).on('hashchange.ckeditor', redirectTextareaFragmentToCKEditorInstance);
// Set the CKEditor cache-busting string to the same value as Drupal.
CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp;
})(Drupal, Drupal.debounce, CKEDITOR, jQuery);

View file

@ -0,0 +1,16 @@
(function ($, Drupal) {
'use strict';
/**
* Provides the summary for the "language" plugin settings vertical tab.
*/
Drupal.behaviors.ckeditorLanguageSettingsSummary = {
attach: function () {
$('#edit-editor-settings-plugins-language').drupalSetSummary(function (context) {
return $('#edit-editor-settings-plugins-language-language-list-type option:selected').text();
});
}
};
})(jQuery, Drupal);

View file

@ -0,0 +1,128 @@
/**
* @file
* CKEditor StylesCombo admin behavior.
*/
(function ($, Drupal, drupalSettings, _) {
'use strict';
/**
* Ensures that the "stylescombo" button's metadata remains up-to-date.
*
* Triggers the CKEditorPluginSettingsChanged event whenever the "stylescombo"
* plugin settings change, to ensure that the corresponding feature metadata
* is immediately updated i.e. ensure that HTML tags and classes entered
* here are known to be "required", which may affect filter settings.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches admin behaviour to the "stylescombo" button.
*/
Drupal.behaviors.ckeditorStylesComboSettings = {
attach: function (context) {
var $context = $(context);
// 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($(this).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} styles
* 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;
}
};
/**
* Provides the summary for the "stylescombo" plugin settings vertical tab.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches summary behaviour to the plugin settings vertical tab.
*/
Drupal.behaviors.ckeditorStylesComboSettingsSummary = {
attach: function () {
$('[data-ckeditor-plugin-id="stylescombo"]').drupalSetSummary(function (context) {
var styles = $.trim($('[data-drupal-selector="edit-editor-settings-plugins-stylescombo-styles"]').val());
if (styles.length === 0) {
return Drupal.t('No styles configured');
}
else {
var count = $.trim(styles).split('\n').length;
return Drupal.t('@count styles configured', {'@count': count});
}
});
}
};
})(jQuery, Drupal, drupalSettings, _);

View file

@ -0,0 +1,75 @@
/**
* @file
* A Backbone Model for the state of a CKEditor toolbar configuration .
*/
(function (Drupal, Backbone) {
'use strict';
/**
* Backbone model for the CKEditor toolbar configuration state.
*
* @constructor
*
* @augments Backbone.Model
*/
Drupal.ckeditor.Model = Backbone.Model.extend(/** @lends Drupal.ckeditor.Model# */{
/**
* Default values.
*
* @type {object}
*/
defaults: /** @lends Drupal.ckeditor.Model# */{
/**
* The CKEditor configuration that is being manipulated through the UI.
*/
activeEditorConfig: null,
/**
* The textarea that contains the serialized representation of the active
* CKEditor configuration.
*/
$textarea: null,
/**
* Tracks whether the active toolbar DOM structure has been changed. When
* true, activeEditorConfig needs to be updated, and when that is updated,
* $textarea will also be updated.
*/
isDirty: false,
/**
* The configuration for the hidden CKEditor instance that is used to
* build the features metadata.
*/
hiddenEditorConfig: null,
/**
* A hash that maps buttons to features.
*/
buttonsToFeatures: null,
/**
* A hash, keyed by a feature name, that details CKEditor plugin features.
*/
featuresMetadata: null,
/**
* Whether the button group names are currently visible.
*/
groupNamesVisible: false
},
/**
* @method
*/
sync: function () {
// Push the settings into the textarea.
this.get('$textarea').val(JSON.stringify(this.get('activeEditorConfig')));
}
});
})(Drupal, Backbone);

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,371 @@
/**
* @file
* Drupal Image plugin.
*
* This alters the existing CKEditor image2 widget plugin to:
* - require a data-entity-type and a data-entity-uuid attribute (which Drupal
* uses to track where images are being used)
* - use a Drupal-native dialog (that is in fact just an alterable Drupal form
* like any other) instead of CKEditor's own dialogs.
*
* @see \Drupal\editor\Form\EditorImageDialog
*
* @ignore
*/
(function ($, Drupal, CKEDITOR) {
'use strict';
CKEDITOR.plugins.add('drupalimage', {
requires: 'image2',
icons: 'drupalimage',
hidpi: true,
beforeInit: function (editor) {
// Override the image2 widget definition to require and handle the
// additional data-entity-type and data-entity-uuid attributes.
editor.on('widgetDefinition', function (event) {
var widgetDefinition = event.data;
if (widgetDefinition.name !== 'image') {
return;
}
// First, convert requiredContent & allowedContent from the string
// format that image2 uses for both to formats that are better suited
// for extending, so that both this basic drupalimage plugin and Drupal
// modules can easily extend it.
// @see http://docs.ckeditor.com/#!/api/CKEDITOR.filter.allowedContentRules
// Mapped from image2's allowedContent. Unlike image2, we don't allow
// <figure>, <figcaption>, <div> or <p> in our downcast, so we omit
// those. For the <img> tag, we list all attributes it lists, but omit
// the classes, because the listed classes are for alignment, and for
// alignment we use the data-align attribute.
widgetDefinition.allowedContent = {
img: {
attributes: {
'!src': true,
'!alt': true,
'width': true,
'height': true
},
classes: {}
}
};
// Mapped from image2's requiredContent: "img[src,alt]". This does not
// use the object format unlike above, but a CKEDITOR.style instance,
// because requiredContent does not support the object format.
// @see https://www.drupal.org/node/2585173#comment-10456981
widgetDefinition.requiredContent = new CKEDITOR.style({
element: 'img',
attributes: {
src: '',
alt: ''
}
});
// Extend requiredContent & allowedContent.
// CKEDITOR.style is an immutable object: we cannot modify its
// definition to extend requiredContent. Hence we get the definition,
// modify it, and pass it to a new CKEDITOR.style instance.
var requiredContent = widgetDefinition.requiredContent.getDefinition();
requiredContent.attributes['data-entity-type'] = '';
requiredContent.attributes['data-entity-uuid'] = '';
widgetDefinition.requiredContent = new CKEDITOR.style(requiredContent);
widgetDefinition.allowedContent.img.attributes['!data-entity-type'] = true;
widgetDefinition.allowedContent.img.attributes['!data-entity-uuid'] = true;
// Override downcast(): since we only accept <img> in our upcast method,
// the element is already correct. We only need to update the element's
// data-entity-uuid attribute.
widgetDefinition.downcast = function (element) {
element.attributes['data-entity-type'] = this.data['data-entity-type'];
element.attributes['data-entity-uuid'] = this.data['data-entity-uuid'];
};
// We want to upcast <img> elements to a DOM structure required by the
// image2 widget; we only accept an <img> tag, and that <img> tag MAY
// have a data-entity-type and a data-entity-uuid attribute.
widgetDefinition.upcast = function (element, data) {
if (element.name !== 'img') {
return;
}
// Don't initialize on pasted fake objects.
else if (element.attributes['data-cke-realelement']) {
return;
}
// Parse the data-entity-type attribute.
data['data-entity-type'] = element.attributes['data-entity-type'];
// Parse the data-entity-uuid attribute.
data['data-entity-uuid'] = element.attributes['data-entity-uuid'];
return element;
};
// Overrides default implementation. Used to populate the "classes"
// property of the widget's "data" property, which is used for the
// "widget styles" functionality
// (http://docs.ckeditor.com/#!/guide/dev_styles-section-widget-styles).
// Is applied to whatever the main element of the widget is (<figure> or
// <img>). The classes in image2_captionedClass are always added due to
// a bug in CKEditor. In the case of drupalimage, we don't ever want to
// add that class, because the widget template already contains it.
// @see http://dev.ckeditor.com/ticket/13888
// @see https://www.drupal.org/node/2268941
var originalGetClasses = widgetDefinition.getClasses;
widgetDefinition.getClasses = function () {
var classes = originalGetClasses.call(this);
var captionedClasses = (this.editor.config.image2_captionedClass || '').split(/\s+/);
if (captionedClasses.length && classes) {
for (var i = 0; i < captionedClasses.length; i++) {
if (captionedClasses[i] in classes) {
delete classes[captionedClasses[i]];
}
}
}
return classes;
};
// Protected; keys of the widget data to be sent to the Drupal dialog.
// Keys in the hash are the keys for image2's data, values are the keys
// that the Drupal dialog uses.
widgetDefinition._mapDataToDialog = {
'src': 'src',
'alt': 'alt',
'width': 'width',
'height': 'height',
'data-entity-type': 'data-entity-type',
'data-entity-uuid': 'data-entity-uuid'
};
// Protected; transforms widget's data object to the format used by the
// \Drupal\editor\Form\EditorImageDialog dialog, keeping only the data
// listed in widgetDefinition._dataForDialog.
widgetDefinition._dataToDialogValues = function (data) {
var dialogValues = {};
var map = widgetDefinition._mapDataToDialog;
Object.keys(widgetDefinition._mapDataToDialog).forEach(function (key) {
dialogValues[map[key]] = data[key];
});
return dialogValues;
};
// Protected; the inverse of _dataToDialogValues.
widgetDefinition._dialogValuesToData = function (dialogReturnValues) {
var data = {};
var map = widgetDefinition._mapDataToDialog;
Object.keys(widgetDefinition._mapDataToDialog).forEach(function (key) {
if (dialogReturnValues.hasOwnProperty(map[key])) {
data[key] = dialogReturnValues[map[key]];
}
});
return data;
};
// Protected; creates Drupal dialog save callback.
widgetDefinition._createDialogSaveCallback = function (editor, widget) {
return function (dialogReturnValues) {
var firstEdit = !widget.ready;
// Dialog may have blurred the widget. Re-focus it first.
if (!firstEdit) {
widget.focus();
}
editor.fire('saveSnapshot');
// Pass `true` so DocumentFragment will also be returned.
var container = widget.wrapper.getParent(true);
var image = widget.parts.image;
// Set the updated widget data, after the necessary conversions from
// the dialog's return values.
// Note: on widget#setData this widget instance might be destroyed.
var data = widgetDefinition._dialogValuesToData(dialogReturnValues.attributes);
widget.setData(data);
// Retrieve the widget once again. It could've been destroyed
// when shifting state, so might deal with a new instance.
widget = editor.widgets.getByElement(image);
// It's first edit, just after widget instance creation, but before
// it was inserted into DOM. So we need to retrieve the widget
// wrapper from inside the DocumentFragment which we cached above
// and finalize other things (like ready event and flag).
if (firstEdit) {
editor.widgets.finalizeCreation(container);
}
setTimeout(function () {
// (Re-)focus the widget.
widget.focus();
// Save snapshot for undo support.
editor.fire('saveSnapshot');
});
return widget;
};
};
var originalInit = widgetDefinition.init;
widgetDefinition.init = function () {
originalInit.call(this);
// Update data.link object with attributes if the link has been
// discovered.
// @see plugins/image2/plugin.js/init() in CKEditor; this is similar.
if (this.parts.link) {
this.setData('link', CKEDITOR.plugins.image2.getLinkAttributesParser()(editor, this.parts.link));
}
};
});
// Add a widget#edit listener to every instance of image2 widget in order
// to handle its editing with a Drupal-native dialog.
// This includes also a case just after the image was created
// and dialog should be opened for it for the first time.
editor.widgets.on('instanceCreated', function (event) {
var widget = event.data;
if (widget.name !== 'image') {
return;
}
widget.on('edit', function (event) {
// Cancel edit event to break image2's dialog binding
// (and also to prevent automatic insertion before opening dialog).
event.cancel();
// Open drupalimage dialog.
editor.execCommand('editdrupalimage', {
existingValues: widget.definition._dataToDialogValues(widget.data),
saveCallback: widget.definition._createDialogSaveCallback(editor, widget),
// Drupal.t() will not work inside CKEditor plugins because CKEditor
// loads the JavaScript file instead of Drupal. Pull translated
// strings from the plugin settings that are translated server-side.
dialogTitle: widget.data.src ? editor.config.drupalImage_dialogTitleEdit : editor.config.drupalImage_dialogTitleAdd
});
});
});
// Register the "editdrupalimage" command, which essentially just replaces
// the "image" command's CKEditor dialog with a Drupal-native dialog.
editor.addCommand('editdrupalimage', {
allowedContent: 'img[alt,!src,width,height,!data-entity-type,!data-entity-uuid]',
requiredContent: 'img[alt,src,data-entity-type,data-entity-uuid]',
modes: {wysiwyg: 1},
canUndo: true,
exec: function (editor, data) {
var dialogSettings = {
title: data.dialogTitle,
dialogClass: 'editor-image-dialog'
};
Drupal.ckeditor.openDialog(editor, Drupal.url('editor/dialog/image/' + editor.config.drupal.format), data.existingValues, data.saveCallback, dialogSettings);
}
});
// Register the toolbar button.
if (editor.ui.addButton) {
editor.ui.addButton('DrupalImage', {
label: Drupal.t('Image'),
// Note that we use the original image2 command!
command: 'image'
});
}
},
afterInit: function (editor) {
linkCommandIntegrator(editor);
}
});
// Override image2's integration with the official CKEditor link plugin:
// integrate with the drupallink plugin instead.
CKEDITOR.plugins.image2.getLinkAttributesParser = function () {
return CKEDITOR.plugins.drupallink.parseLinkAttributes;
};
CKEDITOR.plugins.image2.getLinkAttributesGetter = function () {
return CKEDITOR.plugins.drupallink.getLinkAttributes;
};
/**
* Integrates the drupalimage widget with the drupallink plugin.
*
* Makes images linkable.
*
* @param {CKEDITOR.editor} editor
* A CKEditor instance.
*/
function linkCommandIntegrator(editor) {
// Nothing to integrate with if the drupallink plugin is not loaded.
if (!editor.plugins.drupallink) {
return;
}
// Override default behaviour of 'drupalunlink' command.
editor.getCommand('drupalunlink').on('exec', function (evt) {
var widget = getFocusedWidget(editor);
// Override 'drupalunlink' only when link truly belongs to the widget. If
// wrapped inline widget in a link, let default unlink work.
// @see https://dev.ckeditor.com/ticket/11814
if (!widget || !widget.parts.link) {
return;
}
widget.setData('link', null);
// Selection (which is fake) may not change if unlinked image in focused
// widget, i.e. if captioned image. Let's refresh command state manually
// here.
this.refresh(editor, editor.elementPath());
evt.cancel();
});
// Override default refresh of 'drupalunlink' command.
editor.getCommand('drupalunlink').on('refresh', function (evt) {
var widget = getFocusedWidget(editor);
if (!widget) {
return;
}
// Note that widget may be wrapped in a link, which
// does not belong to that widget (#11814).
this.setState(widget.data.link || widget.wrapper.getAscendant('a') ?
CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED);
evt.cancel();
});
}
/**
* Gets the focused widget, if of the type specific for this plugin.
*
* @param {CKEDITOR.editor} editor
* A CKEditor instance.
*
* @return {?CKEDITOR.plugins.widget}
* The focused image2 widget instance, or null.
*/
function getFocusedWidget(editor) {
var widget = editor.widgets.focused;
if (widget && widget.name === 'image') {
return widget;
}
return null;
}
// Expose an API for other plugins to interact with drupalimage widgets.
CKEDITOR.plugins.drupalimage = {
getFocusedWidget: getFocusedWidget
};
})(jQuery, Drupal, CKEDITOR);

View file

@ -0,0 +1,301 @@
/**
* @file
* Drupal Image Caption plugin.
*
* This alters the existing CKEditor image2 widget plugin, which is already
* altered by the Drupal Image plugin, to:
* - allow for the data-caption and data-align attributes to be set
* - mimic the upcasting behavior of the caption_filter filter.
*
* @ignore
*/
(function (CKEDITOR) {
'use strict';
CKEDITOR.plugins.add('drupalimagecaption', {
requires: 'drupalimage',
beforeInit: function (editor) {
// Disable default placeholder text that comes with CKEditor's image2
// plugin: it has an inferior UX (it requires the user to manually delete
// the place holder text).
editor.lang.image2.captionPlaceholder = '';
// Drupal.t() will not work inside CKEditor plugins because CKEditor loads
// the JavaScript file instead of Drupal. Pull translated strings from the
// plugin settings that are translated server-side.
var placeholderText = editor.config.drupalImageCaption_captionPlaceholderText;
// Override the image2 widget definition to handle the additional
// data-align and data-caption attributes.
editor.on('widgetDefinition', function (event) {
var widgetDefinition = event.data;
if (widgetDefinition.name !== 'image') {
return;
}
// Only perform the downcasting/upcasting for to the enabled filters.
var captionFilterEnabled = editor.config.drupalImageCaption_captionFilterEnabled;
var alignFilterEnabled = editor.config.drupalImageCaption_alignFilterEnabled;
// Override default features definitions for drupalimagecaption.
CKEDITOR.tools.extend(widgetDefinition.features, {
caption: {
requiredContent: 'img[data-caption]'
},
align: {
requiredContent: 'img[data-align]'
}
}, true);
// Extend requiredContent & allowedContent.
// CKEDITOR.style is an immutable object: we cannot modify its
// definition to extend requiredContent. Hence we get the definition,
// modify it, and pass it to a new CKEDITOR.style instance.
var requiredContent = widgetDefinition.requiredContent.getDefinition();
requiredContent.attributes['data-align'] = '';
requiredContent.attributes['data-caption'] = '';
widgetDefinition.requiredContent = new CKEDITOR.style(requiredContent);
widgetDefinition.allowedContent.img.attributes['!data-align'] = true;
widgetDefinition.allowedContent.img.attributes['!data-caption'] = true;
// Override allowedContent setting for the 'caption' nested editable.
// This must match what caption_filter enforces.
// @see \Drupal\filter\Plugin\Filter\FilterCaption::process()
// @see \Drupal\Component\Utility\Xss::filter()
widgetDefinition.editables.caption.allowedContent = 'a[!href]; em strong cite code br';
// Override downcast(): ensure we *only* output <img>, but also ensure
// we include the data-entity-type, data-entity-uuid, data-align and
// data-caption attributes.
var originalDowncast = widgetDefinition.downcast;
widgetDefinition.downcast = function (element) {
var img = findElementByName(element, 'img');
originalDowncast.call(this, img);
var caption = this.editables.caption;
var captionHtml = caption && caption.getData();
var attrs = img.attributes;
if (captionFilterEnabled) {
// If image contains a non-empty caption, serialize caption to the
// data-caption attribute.
if (captionHtml) {
attrs['data-caption'] = captionHtml;
}
}
if (alignFilterEnabled) {
if (this.data.align !== 'none') {
attrs['data-align'] = this.data.align;
}
}
// If img is wrapped with a link, we want to return that link.
if (img.parent.name === 'a') {
return img.parent;
}
else {
return img;
}
};
// We want to upcast <img> elements to a DOM structure required by the
// image2 widget. Depending on a case it may be:
// - just an <img> tag (non-captioned, not-centered image),
// - <img> tag in a paragraph (non-captioned, centered image),
// - <figure> tag (captioned image).
// We take the same attributes into account as downcast() does.
var originalUpcast = widgetDefinition.upcast;
widgetDefinition.upcast = function (element, data) {
if (element.name !== 'img' || !element.attributes['data-entity-type'] || !element.attributes['data-entity-uuid']) {
return;
}
// Don't initialize on pasted fake objects.
else if (element.attributes['data-cke-realelement']) {
return;
}
element = originalUpcast.call(this, element, data);
var attrs = element.attributes;
if (element.parent.name === 'a') {
element = element.parent;
}
var retElement = element;
var caption;
// We won't need the attributes during editing: we'll use widget.data
// to store them (except the caption, which is stored in the DOM).
if (captionFilterEnabled) {
caption = attrs['data-caption'];
delete attrs['data-caption'];
}
if (alignFilterEnabled) {
data.align = attrs['data-align'];
delete attrs['data-align'];
}
data['data-entity-type'] = attrs['data-entity-type'];
delete attrs['data-entity-type'];
data['data-entity-uuid'] = attrs['data-entity-uuid'];
delete attrs['data-entity-uuid'];
if (captionFilterEnabled) {
// Unwrap from <p> wrapper created by HTML parser for a captioned
// image. The captioned image will be transformed to <figure>, so we
// don't want the <p> anymore.
if (element.parent.name === 'p' && caption) {
var index = element.getIndex();
var splitBefore = index > 0;
var splitAfter = index + 1 < element.parent.children.length;
if (splitBefore) {
element.parent.split(index);
}
index = element.getIndex();
if (splitAfter) {
element.parent.split(index + 1);
}
element.parent.replaceWith(element);
retElement = element;
}
// If this image has a caption, create a full <figure> structure.
if (caption) {
var figure = new CKEDITOR.htmlParser.element('figure');
caption = new CKEDITOR.htmlParser.fragment.fromHtml(caption, 'figcaption');
// Use Drupal's data-placeholder attribute to insert a CSS-based,
// translation-ready placeholder for empty captions. Note that it
// also must to be done for new instances (see
// widgetDefinition._createDialogSaveCallback).
caption.attributes['data-placeholder'] = placeholderText;
element.replaceWith(figure);
figure.add(element);
figure.add(caption);
figure.attributes['class'] = editor.config.image2_captionedClass;
retElement = figure;
}
}
if (alignFilterEnabled) {
// If this image doesn't have a caption (or the caption filter is
// disabled), but it is centered, make sure that it's wrapped with
// <p>, which will become a part of the widget.
if (data.align === 'center' && (!captionFilterEnabled || !caption)) {
var p = new CKEDITOR.htmlParser.element('p');
element.replaceWith(p);
p.add(element);
// Apply the class for centered images.
p.addClass(editor.config.image2_alignClasses[1]);
retElement = p;
}
}
// Return the upcasted element (<img>, <figure> or <p>).
return retElement;
};
// Protected; keys of the widget data to be sent to the Drupal dialog.
// Append to the values defined by the drupalimage plugin.
// @see core/modules/ckeditor/js/plugins/drupalimage/plugin.js
CKEDITOR.tools.extend(widgetDefinition._mapDataToDialog, {
'align': 'data-align',
'data-caption': 'data-caption',
'hasCaption': 'hasCaption'
});
// Override Drupal dialog save callback.
var originalCreateDialogSaveCallback = widgetDefinition._createDialogSaveCallback;
widgetDefinition._createDialogSaveCallback = function (editor, widget) {
var saveCallback = originalCreateDialogSaveCallback.call(this, editor, widget);
return function (dialogReturnValues) {
// Ensure hasCaption is a boolean. image2 assumes it always works
// with booleans; if this is not the case, then
// CKEDITOR.plugins.image2.stateShifter() will incorrectly mark
// widget.data.hasCaption as "changed" (e.g. when hasCaption === 0
// instead of hasCaption === false). This causes image2's "state
// shifter" to enter the wrong branch of the algorithm and blow up.
dialogReturnValues.attributes.hasCaption = !!dialogReturnValues.attributes.hasCaption;
var actualWidget = saveCallback(dialogReturnValues);
// By default, the template of captioned widget has no
// data-placeholder attribute. Note that it also must be done when
// upcasting existing elements (see widgetDefinition.upcast).
if (dialogReturnValues.attributes.hasCaption) {
actualWidget.editables.caption.setAttribute('data-placeholder', placeholderText);
// Some browsers will add a <br> tag to a newly created DOM
// element with no content. Remove this <br> if it is the only
// thing in the caption. Our placeholder support requires the
// element be entirely empty. See filter-caption.css.
var captionElement = actualWidget.editables.caption.$;
if (captionElement.childNodes.length === 1 && captionElement.childNodes.item(0).nodeName === 'BR') {
captionElement.removeChild(captionElement.childNodes.item(0));
}
}
};
};
// Low priority to ensure drupalimage's event handler runs first.
}, null, null, 20);
},
afterInit: function (editor) {
var disableButtonIfOnWidget = function (evt) {
var widget = editor.widgets.focused;
if (widget && widget.name === 'image') {
this.setState(CKEDITOR.TRISTATE_DISABLED);
evt.cancel();
}
};
// Disable alignment buttons if the align filter is not enabled.
if (editor.plugins.justify && !editor.config.drupalImageCaption_alignFilterEnabled) {
var cmd;
var commands = ['justifyleft', 'justifycenter', 'justifyright', 'justifyblock'];
for (var n = 0; n < commands.length; n++) {
cmd = editor.getCommand(commands[n]);
cmd.contextSensitive = 1;
cmd.on('refresh', disableButtonIfOnWidget, null, null, 4);
}
}
}
});
/**
* Finds an element by its name.
*
* Function will check first the passed element itself and then all its
* children in DFS order.
*
* @param {CKEDITOR.htmlParser.element} element
* The element to search.
* @param {string} name
* The element name to search for.
*
* @return {?CKEDITOR.htmlParser.element}
* The found element, or null.
*/
function findElementByName(element, name) {
if (element.name === name) {
return element;
}
var found = null;
element.forEach(function (el) {
if (el.name === name) {
found = el;
// Stop here.
return false;
}
}, CKEDITOR.NODE_ELEMENT);
return found;
}
})(CKEDITOR);

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,304 @@
/**
* @file
* Drupal Link plugin.
*
* @ignore
*/
(function ($, Drupal, drupalSettings, CKEDITOR) {
'use strict';
function parseAttributes(editor, element) {
var parsedAttributes = {};
var domElement = element.$;
var attribute = null;
var attributeName;
for (var attrIndex = 0; attrIndex < domElement.attributes.length; attrIndex++) {
attribute = domElement.attributes.item(attrIndex);
attributeName = attribute.nodeName.toLowerCase();
// Ignore data-cke-* attributes; they're CKEditor internals.
if (attributeName.indexOf('data-cke-') === 0) {
continue;
}
// Store the value for this attribute, unless there's a data-cke-saved-
// alternative for it, which will contain the quirk-free, original value.
parsedAttributes[attributeName] = element.data('cke-saved-' + attributeName) || attribute.nodeValue;
}
// Remove any cke_* classes.
if (parsedAttributes.class) {
parsedAttributes.class = CKEDITOR.tools.trim(parsedAttributes.class.replace(/cke_\S+/, ''));
}
return parsedAttributes;
}
function getAttributes(editor, data) {
var set = {};
for (var attributeName in data) {
if (data.hasOwnProperty(attributeName)) {
set[attributeName] = data[attributeName];
}
}
// CKEditor tracks the *actual* saved href in a data-cke-saved-* attribute
// to work around browser quirks. We need to update it.
set['data-cke-saved-href'] = set.href;
// Remove all attributes which are not currently set.
var removed = {};
for (var s in set) {
if (set.hasOwnProperty(s)) {
delete removed[s];
}
}
return {
set: set,
removed: CKEDITOR.tools.objectKeys(removed)
};
}
CKEDITOR.plugins.add('drupallink', {
icons: 'drupallink,drupalunlink',
hidpi: true,
init: function (editor) {
// Add the commands for link and unlink.
editor.addCommand('drupallink', {
allowedContent: {
a: {
attributes: {
'!href': true
},
classes: {}
}
},
requiredContent: new CKEDITOR.style({
element: 'a',
attributes: {
href: ''
}
}),
modes: {wysiwyg: 1},
canUndo: true,
exec: function (editor) {
var drupalImageUtils = CKEDITOR.plugins.drupalimage;
var focusedImageWidget = drupalImageUtils && drupalImageUtils.getFocusedWidget(editor);
var linkElement = getSelectedLink(editor);
// Set existing values based on selected element.
var existingValues = {};
if (linkElement && linkElement.$) {
existingValues = parseAttributes(editor, linkElement);
}
// Or, if an image widget is focused, we're editing a link wrapping
// an image widget.
else if (focusedImageWidget && focusedImageWidget.data.link) {
existingValues = CKEDITOR.tools.clone(focusedImageWidget.data.link);
}
// Prepare a save callback to be used upon saving the dialog.
var saveCallback = function (returnValues) {
// If an image widget is focused, we're not editing an independent
// link, but we're wrapping an image widget in a link.
if (focusedImageWidget) {
focusedImageWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedImageWidget.data.link));
editor.fire('saveSnapshot');
return;
}
editor.fire('saveSnapshot');
// Create a new link element if needed.
if (!linkElement && returnValues.attributes.href) {
var selection = editor.getSelection();
var range = selection.getRanges(1)[0];
// Use link URL as text with a collapsed cursor.
if (range.collapsed) {
// Shorten mailto URLs to just the email address.
var text = new CKEDITOR.dom.text(returnValues.attributes.href.replace(/^mailto:/, ''), editor.document);
range.insertNode(text);
range.selectNodeContents(text);
}
// Create the new link by applying a style to the new text.
var style = new CKEDITOR.style({element: 'a', attributes: returnValues.attributes});
style.type = CKEDITOR.STYLE_INLINE;
style.applyToRange(range);
range.select();
// Set the link so individual properties may be set below.
linkElement = getSelectedLink(editor);
}
// Update the link properties.
else if (linkElement) {
for (var attrName in returnValues.attributes) {
if (returnValues.attributes.hasOwnProperty(attrName)) {
// Update the property if a value is specified.
if (returnValues.attributes[attrName].length > 0) {
var value = returnValues.attributes[attrName];
linkElement.data('cke-saved-' + attrName, value);
linkElement.setAttribute(attrName, value);
}
// Delete the property if set to an empty string.
else {
linkElement.removeAttribute(attrName);
}
}
}
}
// Save snapshot for undo support.
editor.fire('saveSnapshot');
};
// Drupal.t() will not work inside CKEditor plugins because CKEditor
// loads the JavaScript file instead of Drupal. Pull translated
// strings from the plugin settings that are translated server-side.
var dialogSettings = {
title: linkElement ? editor.config.drupalLink_dialogTitleEdit : editor.config.drupalLink_dialogTitleAdd,
dialogClass: 'editor-link-dialog'
};
// Open the dialog for the edit form.
Drupal.ckeditor.openDialog(editor, Drupal.url('editor/dialog/link/' + editor.config.drupal.format), existingValues, saveCallback, dialogSettings);
}
});
editor.addCommand('drupalunlink', {
contextSensitive: 1,
startDisabled: 1,
requiredContent: new CKEDITOR.style({
element: 'a',
attributes: {
href: ''
}
}),
exec: function (editor) {
var style = new CKEDITOR.style({element: 'a', type: CKEDITOR.STYLE_INLINE, alwaysRemoveElement: 1});
editor.removeStyle(style);
},
refresh: function (editor, path) {
var element = path.lastElement && path.lastElement.getAscendant('a', true);
if (element && element.getName() === 'a' && element.getAttribute('href') && element.getChildCount()) {
this.setState(CKEDITOR.TRISTATE_OFF);
}
else {
this.setState(CKEDITOR.TRISTATE_DISABLED);
}
}
});
// CTRL + K.
editor.setKeystroke(CKEDITOR.CTRL + 75, 'drupallink');
// Add buttons for link and unlink.
if (editor.ui.addButton) {
editor.ui.addButton('DrupalLink', {
label: Drupal.t('Link'),
command: 'drupallink'
});
editor.ui.addButton('DrupalUnlink', {
label: Drupal.t('Unlink'),
command: 'drupalunlink'
});
}
editor.on('doubleclick', function (evt) {
var element = getSelectedLink(editor) || evt.data.element;
if (!element.isReadOnly()) {
if (element.is('a')) {
editor.getSelection().selectElement(element);
editor.getCommand('drupallink').exec();
}
}
});
// If the "menu" plugin is loaded, register the menu items.
if (editor.addMenuItems) {
editor.addMenuItems({
link: {
label: Drupal.t('Edit Link'),
command: 'drupallink',
group: 'link',
order: 1
},
unlink: {
label: Drupal.t('Unlink'),
command: 'drupalunlink',
group: 'link',
order: 5
}
});
}
// If the "contextmenu" plugin is loaded, register the listeners.
if (editor.contextMenu) {
editor.contextMenu.addListener(function (element, selection) {
if (!element || element.isReadOnly()) {
return null;
}
var anchor = getSelectedLink(editor);
if (!anchor) {
return null;
}
var menu = {};
if (anchor.getAttribute('href') && anchor.getChildCount()) {
menu = {link: CKEDITOR.TRISTATE_OFF, unlink: CKEDITOR.TRISTATE_OFF};
}
return menu;
});
}
}
});
/**
* Get the surrounding link element of current selection.
*
* The following selection will all return the link element.
*
* @example
* <a href="#">li^nk</a>
* <a href="#">[link]</a>
* text[<a href="#">link]</a>
* <a href="#">li[nk</a>]
* [<b><a href="#">li]nk</a></b>]
* [<a href="#"><b>li]nk</b></a>
*
* @param {CKEDITOR.editor} editor
* The CKEditor editor object
*
* @return {?HTMLElement}
* The selected link element, or null.
*
*/
function getSelectedLink(editor) {
var selection = editor.getSelection();
var selectedElement = selection.getSelectedElement();
if (selectedElement && selectedElement.is('a')) {
return selectedElement;
}
var range = selection.getRanges(true)[0];
if (range) {
range.shrink(CKEDITOR.SHRINK_TEXT);
return editor.elementPath(range.getCommonAncestor()).contains('a', 1);
}
return null;
}
// Expose an API for other plugins to interact with drupallink widgets.
// (Compatible with the official CKEditor link plugin's API:
// http://dev.ckeditor.com/ticket/13885.)
CKEDITOR.plugins.drupallink = {
parseLinkAttributes: parseAttributes,
getLinkAttributes: getAttributes
};
})(jQuery, Drupal, drupalSettings, CKEDITOR);

View file

@ -0,0 +1,233 @@
/**
* @file
* A Backbone View that provides the aural view of CKEditor toolbar
* configuration.
*/
(function (Drupal, Backbone, $) {
'use strict';
Drupal.ckeditor.AuralView = Backbone.View.extend(/** @lends Drupal.ckeditor.AuralView# */{
/**
* @type {object}
*/
events: {
'click .ckeditor-buttons a': 'announceButtonHelp',
'click .ckeditor-multiple-buttons a': 'announceSeparatorHelp',
'focus .ckeditor-button a': 'onFocus',
'focus .ckeditor-button-separator a': 'onFocus',
'focus .ckeditor-toolbar-group': 'onFocus'
},
/**
* Backbone View for CKEditor toolbar configuration; aural UX (output only).
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
// Announce the button and group positions when the model is no longer
// dirty.
this.listenTo(this.model, 'change:isDirty', this.announceMove);
},
/**
* Calls announce on buttons and groups when their position is changed.
*
* @param {Drupal.ckeditor.ConfigurationModel} model
* The ckeditor configuration model.
* @param {bool} isDirty
* A model attribute that indicates if the changed toolbar configuration
* has been stored or not.
*/
announceMove: function (model, isDirty) {
// Announce the position of a button or group after the model has been
// updated.
if (!isDirty) {
var item = document.activeElement || null;
if (item) {
var $item = $(item);
if ($item.hasClass('ckeditor-toolbar-group')) {
this.announceButtonGroupPosition($item);
}
else if ($item.parent().hasClass('ckeditor-button')) {
this.announceButtonPosition($item.parent());
}
}
}
},
/**
* Handles the focus event of elements in the active and available toolbars.
*
* @param {jQuery.Event} event
* The focus event that was triggered.
*/
onFocus: function (event) {
event.stopPropagation();
var $originalTarget = $(event.target);
var $currentTarget = $(event.currentTarget);
var $parent = $currentTarget.parent();
if ($parent.hasClass('ckeditor-button') || $parent.hasClass('ckeditor-button-separator')) {
this.announceButtonPosition($currentTarget.parent());
}
else if ($originalTarget.attr('role') !== 'button' && $currentTarget.hasClass('ckeditor-toolbar-group')) {
this.announceButtonGroupPosition($currentTarget);
}
},
/**
* Announces the current position of a button group.
*
* @param {jQuery} $group
* A jQuery set that contains an li element that wraps a group of buttons.
*/
announceButtonGroupPosition: function ($group) {
var $groups = $group.parent().children();
var $row = $group.closest('.ckeditor-row');
var $rows = $row.parent().children();
var position = $groups.index($group) + 1;
var positionCount = $groups.not('.placeholder').length;
var row = $rows.index($row) + 1;
var rowCount = $rows.not('.placeholder').length;
var text = Drupal.t('@groupName button group in position @position of @positionCount in row @row of @rowCount.', {
'@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name'),
'@position': position,
'@positionCount': positionCount,
'@row': row,
'@rowCount': rowCount
});
// If this position is the first in the last row then tell the user that
// pressing the down arrow key will create a new row.
if (position === 1 && row === rowCount) {
text += '\n';
text += Drupal.t('Press the down arrow key to create a new row.');
}
Drupal.announce(text, 'assertive');
},
/**
* Announces current button position.
*
* @param {jQuery} $button
* A jQuery set that contains an li element that wraps a button.
*/
announceButtonPosition: function ($button) {
var $row = $button.closest('.ckeditor-row');
var $rows = $row.parent().children();
var $buttons = $button.closest('.ckeditor-buttons').children();
var $group = $button.closest('.ckeditor-toolbar-group');
var $groups = $group.parent().children();
var groupPosition = $groups.index($group) + 1;
var groupPositionCount = $groups.not('.placeholder').length;
var position = $buttons.index($button) + 1;
var positionCount = $buttons.length;
var row = $rows.index($row) + 1;
var rowCount = $rows.not('.placeholder').length;
// The name of the button separator is 'button separator' and its type
// is 'separator', so we do not want to print the type of this item,
// otherwise the UA will speak 'button separator separator'.
var type = ($button.attr('data-drupal-ckeditor-type') === 'separator') ? '' : Drupal.t('button');
var text;
// The button is located in the available button set.
if ($button.closest('.ckeditor-toolbar-disabled').length > 0) {
text = Drupal.t('@name @type.', {
'@name': $button.children().attr('aria-label'),
'@type': type
});
text += '\n' + Drupal.t('Press the down arrow key to activate.');
Drupal.announce(text, 'assertive');
}
// The button is in the active toolbar.
else if ($group.not('.placeholder').length === 1) {
text = Drupal.t('@name @type in position @position of @positionCount in @groupName button group in row @row of @rowCount.', {
'@name': $button.children().attr('aria-label'),
'@type': type,
'@position': position,
'@positionCount': positionCount,
'@groupName': $group.attr('data-drupal-ckeditor-toolbar-group-name'),
'@row': row,
'@rowCount': rowCount
});
// If this position is the first in the last row then tell the user that
// pressing the down arrow key will create a new row.
if (groupPosition === 1 && position === 1 && row === rowCount) {
text += '\n';
text += Drupal.t('Press the down arrow key to create a new button group in a new row.');
}
// If this position is the last one in this row then tell the user that
// moving the button to the next group will create a new group.
if (groupPosition === groupPositionCount && position === positionCount) {
text += '\n';
text += Drupal.t('This is the last group. Move the button forward to create a new group.');
}
Drupal.announce(text, 'assertive');
}
},
/**
* Provides help information when a button is clicked.
*
* @param {jQuery.Event} event
* The click event for the button click.
*/
announceButtonHelp: function (event) {
var $link = $(event.currentTarget);
var $button = $link.parent();
var enabled = $button.closest('.ckeditor-toolbar-active').length > 0;
var message;
if (enabled) {
message = Drupal.t('The "@name" button is currently enabled.', {
'@name': $link.attr('aria-label')
});
message += '\n' + Drupal.t('Use the keyboard arrow keys to change the position of this button.');
message += '\n' + Drupal.t('Press the up arrow key on the top row to disable the button.');
}
else {
message = Drupal.t('The "@name" button is currently disabled.', {
'@name': $link.attr('aria-label')
});
message += '\n' + Drupal.t('Use the down arrow key to move this button into the active toolbar.');
}
Drupal.announce(message);
event.preventDefault();
},
/**
* Provides help information when a separator is clicked.
*
* @param {jQuery.Event} event
* The click event for the separator click.
*/
announceSeparatorHelp: function (event) {
var $link = $(event.currentTarget);
var $button = $link.parent();
var enabled = $button.closest('.ckeditor-toolbar-active').length > 0;
var message;
if (enabled) {
message = Drupal.t('This @name is currently enabled.', {
'@name': $link.attr('aria-label')
});
message += '\n' + Drupal.t('Use the keyboard arrow keys to change the position of this separator.');
}
else {
message = Drupal.t('Separators are used to visually split individual buttons.');
message += '\n' + Drupal.t('This @name is currently disabled.', {
'@name': $link.attr('aria-label')
});
message += '\n' + Drupal.t('Use the down arrow key to move this separator into the active toolbar.');
message += '\n' + Drupal.t('You may add multiple separators to each button group.');
}
Drupal.announce(message);
event.preventDefault();
}
});
})(Drupal, Backbone, jQuery);

View file

@ -0,0 +1,383 @@
/**
* @file
* A Backbone View acting as a controller for CKEditor toolbar configuration.
*/
(function ($, Drupal, Backbone, CKEDITOR, _) {
'use strict';
Drupal.ckeditor.ControllerView = Backbone.View.extend(/** @lends Drupal.ckeditor.ControllerView# */{
/**
* @type {object}
*/
events: {},
/**
* Backbone View acting as a controller for CKEditor toolbar configuration.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
this.getCKEditorFeatures(this.model.get('hiddenEditorConfig'), this.disableFeaturesDisallowedByFilters.bind(this));
// Push the active editor configuration to the textarea.
this.model.listenTo(this.model, 'change:activeEditorConfig', this.model.sync);
this.listenTo(this.model, 'change:isDirty', this.parseEditorDOM);
},
/**
* Converts the active toolbar DOM structure to an object representation.
*
* @param {Drupal.ckeditor.ConfigurationModel} model
* The state model for the CKEditor configuration.
* @param {bool} isDirty
* Tracks whether the active toolbar DOM structure has been changed.
* isDirty is toggled back to false in this method.
* @param {object} options
* An object that includes:
* @param {bool} [options.broadcast]
* A flag that controls whether a CKEditorToolbarChanged event should be
* fired for configuration changes.
*
* @fires event:CKEditorToolbarChanged
*/
parseEditorDOM: function (model, isDirty, options) {
if (isDirty) {
var currentConfig = this.model.get('activeEditorConfig');
// Process the rows.
var rows = [];
this.$el
.find('.ckeditor-active-toolbar-configuration')
.children('.ckeditor-row').each(function () {
var groups = [];
// Process the button groups.
$(this).find('.ckeditor-toolbar-group').each(function () {
var $group = $(this);
var $buttons = $group.find('.ckeditor-button');
if ($buttons.length) {
var group = {
name: $group.attr('data-drupal-ckeditor-toolbar-group-name'),
items: []
};
$group.find('.ckeditor-button, .ckeditor-multiple-button').each(function () {
group.items.push($(this).attr('data-drupal-ckeditor-button-name'));
});
groups.push(group);
}
});
if (groups.length) {
rows.push(groups);
}
});
this.model.set('activeEditorConfig', rows);
// Mark the model as clean. Whether or not the sync to the textfield
// occurs depends on the activeEditorConfig attribute firing a change
// event. The DOM has at least been processed and posted, so as far as
// the model is concerned, it is clean.
this.model.set('isDirty', false);
// Determine whether we should trigger an event.
if (options.broadcast !== false) {
var prev = this.getButtonList(currentConfig);
var next = this.getButtonList(rows);
if (prev.length !== next.length) {
this.$el
.find('.ckeditor-toolbar-active')
.trigger('CKEditorToolbarChanged', [
(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 {@link Drupal.EditorFeature}
* features keyed by feature (button) name.
*
* @param {object} CKEditorConfig
* An object that represents the configuration settings for a CKEditor
* editor component.
* @param {function} callback
* A function to invoke when the instanceReady event is fired by the
* CKEditor object.
*/
getCKEditorFeatures: function (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\CKEditor::settingsForm.
var hiddenCKEditorID = 'ckeditor-hidden';
if (CKEDITOR.instances[hiddenCKEditorID]) {
CKEDITOR.instances[hiddenCKEditorID].destroy(true);
}
// Load external plugins, if any.
var hiddenEditorConfig = this.model.get('hiddenEditorConfig');
if (hiddenEditorConfig.drupalExternalPlugins) {
var externalPlugins = hiddenEditorConfig.drupalExternalPlugins;
for (var pluginName in externalPlugins) {
if (externalPlugins.hasOwnProperty(pluginName)) {
CKEDITOR.plugins.addExternal(pluginName, externalPlugins[pluginName], '');
}
}
}
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;
var 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. And track which
// buttons are mapped to which features.
// @see getFeatureForButton()
var features = {};
var buttonsToFeatures = {};
for (var featureName in CKEFeatureRulesMap) {
if (CKEFeatureRulesMap.hasOwnProperty(featureName)) {
var feature = new Drupal.EditorFeature(featureName);
convertCKERulesToEditorFeature(feature, CKEFeatureRulesMap[featureName]);
features[featureName] = feature;
var command = e.editor.getCommand(featureName);
if (command) {
buttonsToFeatures[command.uiItems[0].name] = featureName;
}
}
}
callback(features, buttonsToFeatures);
}
});
},
/**
* Retrieves the feature for a given button from featuresMetadata. Returns
* false if the given button is in fact a divider.
*
* @param {string} button
* The name of a CKEditor button.
*
* @return {object}
* The feature metadata object for a button.
*/
getFeatureForButton: function (button) {
// Return false if the button being added is a divider.
if (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 = this.model.get('buttonsToFeatures')[button.toLowerCase()];
// Features without an associated command do not have a 'feature name' by
// default, so we use the lowercased button name instead.
if (!featureName) {
featureName = button.toLowerCase();
}
var featuresMetadata = this.model.get('featuresMetadata');
if (!featuresMetadata[featureName]) {
featuresMetadata[featureName] = new Drupal.EditorFeature(featureName);
this.model.set('featuresMetadata', featuresMetadata);
}
return featuresMetadata[featureName];
},
/**
* Checks buttons against filter settings; disables disallowed buttons.
*
* @param {object} features
* A map of {@link Drupal.EditorFeature} objects.
* @param {object} buttonsToFeatures
* Object containing the button-to-feature mapping.
*
* @see Drupal.ckeditor.ControllerView#getFeatureForButton
*/
disableFeaturesDisallowedByFilters: function (features, buttonsToFeatures) {
this.model.set('featuresMetadata', features);
// Store the button-to-feature mapping. Needs to happen only once, because
// the same buttons continue to have the same features; only the rules for
// specific features may change.
// @see getFeatureForButton()
this.model.set('buttonsToFeatures', buttonsToFeatures);
// Ensure that toolbar configuration changes are broadcast.
this.broadcastConfigurationChanges(this.$el);
// 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 existingButtons = [];
// Loop through each button group after flattening the groups from the
// toolbar row arrays.
var buttonGroups = _.flatten(this.model.get('activeEditorConfig'));
for (var i = 0; i < buttonGroups.length; i++) {
// Pull the button names from each toolbar button group.
var buttons = buttonGroups[i].items;
for (var k = 0; k < buttons.length; k++) {
existingButtons.push(buttons[k]);
}
}
// Remove duplicate buttons.
existingButtons = _.unique(existingButtons);
// Prepare the active toolbar and available-button toolbars.
for (var n = 0; n < existingButtons.length; n++) {
var button = existingButtons[n];
var feature = this.getFeatureForButton(button);
// Skip dividers.
if (feature === false) {
continue;
}
if (Drupal.editorConfiguration.featureIsAllowedByFilters(feature)) {
// Existing toolbar buttons are in fact "added features".
this.$el.find('.ckeditor-toolbar-active').trigger('CKEditorToolbarChanged', ['added', existingButtons[n]]);
}
else {
// Move the button element from the active the active toolbar to the
// list of available buttons.
$('.ckeditor-toolbar-active li[data-drupal-ckeditor-button-name="' + button + '"]')
.detach()
.appendTo('.ckeditor-toolbar-disabled > .ckeditor-toolbar-available > ul');
// Update the toolbar value field.
this.model.set({isDirty: true}, {broadcast: false});
}
}
},
/**
* Sets up broadcasting of CKEditor toolbar configuration changes.
*
* @param {jQuery} $ckeditorToolbar
* The active toolbar DOM element wrapped in jQuery.
*/
broadcastConfigurationChanges: function ($ckeditorToolbar) {
var view = this;
var hiddenEditorConfig = this.model.get('hiddenEditorConfig');
var getFeatureForButton = this.getFeatureForButton.bind(this);
var getCKEditorFeatures = this.getCKEditorFeatures.bind(this);
$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 (event, 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 configEvent = (action === 'added') ? 'addedFeature' : 'removedFeature';
Drupal.editorConfiguration[configEvent](feature);
})
// Listen for CKEditor plugin settings changes. When a plugin setting is
// changed, rebuild the CKEditor features metadata.
.on('CKEditorPluginSettingsChanged.ckeditorAdmin', function (event, settingsChanges) {
// Update hidden CKEditor configuration.
for (var key in settingsChanges) {
if (settingsChanges.hasOwnProperty(key)) {
hiddenEditorConfig[key] = settingsChanges[key];
}
}
// Retrieve features for the updated hidden CKEditor configuration.
getCKEditorFeatures(hiddenEditorConfig, function (features) {
// Trigger a standardized text editor configuration event for each
// feature that was modified by the configuration changes.
var featuresMetadata = view.model.get('featuresMetadata');
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.
view.model.set('featuresMetadata', features);
});
});
},
/**
* Returns the list of buttons from an editor configuration.
*
* @param {object} config
* A CKEditor configuration object.
*
* @return {Array}
* A list of buttons in the CKEditor configuration.
*/
getButtonList: function (config) {
var buttons = [];
// Remove the rows.
config = _.flatten(config);
// Loop through the button groups and pull out the buttons.
config.forEach(function (group) {
group.items.forEach(function (button) {
buttons.push(button);
});
});
// Remove the dividing elements if any.
return _.without(buttons, '-');
}
});
})(jQuery, Drupal, Backbone, CKEDITOR, _);

View file

@ -0,0 +1,266 @@
/**
* @file
* Backbone View providing the aural view of CKEditor keyboard UX configuration.
*/
(function ($, Drupal, Backbone, _) {
'use strict';
Drupal.ckeditor.KeyboardView = Backbone.View.extend(/** @lends Drupal.ckeditor.KeyboardView# */{
/**
* Backbone View for CKEditor toolbar configuration; keyboard UX.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
// Add keyboard arrow support.
this.$el.on('keydown.ckeditor', '.ckeditor-buttons a, .ckeditor-multiple-buttons a', this.onPressButton.bind(this));
this.$el.on('keydown.ckeditor', '[data-drupal-ckeditor-type="group"]', this.onPressGroup.bind(this));
},
/**
* @inheritdoc
*/
render: function () {
},
/**
* Handles keypresses on a CKEditor configuration button.
*
* @param {jQuery.Event} event
* The keypress event triggered.
*/
onPressButton: function (event) {
var upDownKeys = [
38, // Up arrow.
63232, // Safari up arrow.
40, // Down arrow.
63233 // Safari down arrow.
];
var leftRightKeys = [
37, // Left arrow.
63234, // Safari left arrow.
39, // Right arrow.
63235 // Safari right arrow.
];
// Respond to an enter key press. Prevent the bubbling of the enter key
// press to the button group parent element.
if (event.keyCode === 13) {
event.stopPropagation();
}
// Only take action when a direction key is pressed.
if (_.indexOf(_.union(upDownKeys, leftRightKeys), event.keyCode) > -1) {
var view = this;
var $target = $(event.currentTarget);
var $button = $target.parent();
var $container = $button.parent();
var $group = $button.closest('.ckeditor-toolbar-group');
var $row = $button.closest('.ckeditor-row');
var containerType = $container.data('drupal-ckeditor-button-sorting');
var $availableButtons = this.$el.find('[data-drupal-ckeditor-button-sorting="source"]');
var $activeButtons = this.$el.find('.ckeditor-toolbar-active');
// The current location of the button, just in case it needs to be put
// back.
var $originalGroup = $group;
var dir;
// Move available buttons between their container and the active
// toolbar.
if (containerType === 'source') {
// Move the button to the active toolbar configuration when the down
// or up keys are pressed.
if (_.indexOf([40, 63233], event.keyCode) > -1) {
// Move the button to the first row, first button group index
// position.
$activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button);
}
}
else if (containerType === 'target') {
// Move buttons between sibling buttons in a group and between groups.
if (_.indexOf(leftRightKeys, event.keyCode) > -1) {
// Move left.
var $siblings = $container.children();
var index = $siblings.index($button);
if (_.indexOf([37, 63234], event.keyCode) > -1) {
// Move between sibling buttons.
if (index > 0) {
$button.insertBefore($container.children().eq(index - 1));
}
// Move between button groups and rows.
else {
// Move between button groups.
$group = $container.parent().prev();
if ($group.length > 0) {
$group.find('.ckeditor-toolbar-group-buttons').append($button);
}
// Wrap between rows.
else {
$container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-group').not('.placeholder').find('.ckeditor-toolbar-group-buttons').eq(-1).append($button);
}
}
}
// Move right.
else if (_.indexOf([39, 63235], event.keyCode) > -1) {
// Move between sibling buttons.
if (index < ($siblings.length - 1)) {
$button.insertAfter($container.children().eq(index + 1));
}
// Move between button groups. Moving right at the end of a row
// will create a new group.
else {
$container.parent().next().find('.ckeditor-toolbar-group-buttons').prepend($button);
}
}
}
// Move buttons between rows and the available button set.
else if (_.indexOf(upDownKeys, event.keyCode) > -1) {
dir = (_.indexOf([38, 63232], event.keyCode) > -1) ? 'prev' : 'next';
$row = $container.closest('.ckeditor-row')[dir]();
// Move the button back into the available button set.
if (dir === 'prev' && $row.length === 0) {
// If this is a divider, just destroy it.
if ($button.data('drupal-ckeditor-type') === 'separator') {
$button
.off()
.remove();
// Focus on the first button in the active toolbar.
$activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).children().eq(0).children().trigger('focus');
}
// Otherwise, move it.
else {
$availableButtons.prepend($button);
}
}
else {
$row.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button);
}
}
}
// Move dividers between their container and the active toolbar.
else if (containerType === 'dividers') {
// Move the button to the active toolbar configuration when the down
// or up keys are pressed.
if (_.indexOf([40, 63233], event.keyCode) > -1) {
// Move the button to the first row, first button group index
// position.
$button = $button.clone(true);
$activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button);
$target = $button.children();
}
}
view = this;
// Attempt to move the button to the new toolbar position.
Drupal.ckeditor.registerButtonMove(this, $button, function (result) {
// Put the button back if the registration failed.
// If the button was in a row, then it was in the active toolbar
// configuration. The button was probably placed in a new group, but
// that action was canceled.
if (!result && $originalGroup) {
$originalGroup.find('.ckeditor-buttons').append($button);
}
// Otherwise refresh the sortables to acknowledge the new button
// positions.
else {
view.$el.find('.ui-sortable').sortable('refresh');
}
// Refocus the target button so that the user can continue from a
// known place.
$target.trigger('focus');
});
event.preventDefault();
event.stopPropagation();
}
},
/**
* Handles keypresses on a CKEditor configuration group.
*
* @param {jQuery.Event} event
* The keypress event triggered.
*/
onPressGroup: function (event) {
var upDownKeys = [
38, // Up arrow.
63232, // Safari up arrow.
40, // Down arrow.
63233 // Safari down arrow.
];
var leftRightKeys = [
37, // Left arrow.
63234, // Safari left arrow.
39, // Right arrow.
63235 // Safari right arrow.
];
// Respond to an enter key press.
if (event.keyCode === 13) {
var view = this;
// Open the group renaming dialog in the next evaluation cycle so that
// this event can be cancelled and the bubbling wiped out. Otherwise,
// Firefox has issues because the page focus is shifted to the dialog
// along with the keydown event.
window.setTimeout(function () {
Drupal.ckeditor.openGroupNameDialog(view, $(event.currentTarget));
}, 0);
event.preventDefault();
event.stopPropagation();
}
// Respond to direction key presses.
if (_.indexOf(_.union(upDownKeys, leftRightKeys), event.keyCode) > -1) {
var $group = $(event.currentTarget);
var $container = $group.parent();
var $siblings = $container.children();
var index;
var dir;
// Move groups between sibling groups.
if (_.indexOf(leftRightKeys, event.keyCode) > -1) {
index = $siblings.index($group);
// Move left between sibling groups.
if ((_.indexOf([37, 63234], event.keyCode) > -1)) {
if (index > 0) {
$group.insertBefore($siblings.eq(index - 1));
}
// Wrap between rows. Insert the group before the placeholder group
// at the end of the previous row.
else {
$group.insertBefore($container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-groups').children().eq(-1));
}
}
// Move right between sibling groups.
else if (_.indexOf([39, 63235], event.keyCode) > -1) {
// Move to the right if the next group is not a placeholder.
if (!$siblings.eq(index + 1).hasClass('placeholder')) {
$group.insertAfter($container.children().eq(index + 1));
}
// Wrap group between rows.
else {
$container.closest('.ckeditor-row').next().find('.ckeditor-toolbar-groups').prepend($group);
}
}
}
// Move groups between rows.
else if (_.indexOf(upDownKeys, event.keyCode) > -1) {
dir = (_.indexOf([38, 63232], event.keyCode) > -1) ? 'prev' : 'next';
$group.closest('.ckeditor-row')[dir]().find('.ckeditor-toolbar-groups').eq(0).prepend($group);
}
Drupal.ckeditor.registerGroupMove(this, $group);
$group.trigger('focus');
event.preventDefault();
event.stopPropagation();
}
}
});
})(jQuery, Drupal, Backbone, _);

View file

@ -0,0 +1,273 @@
/**
* @file
* A Backbone View that provides the visual UX view of CKEditor toolbar
* configuration.
*/
(function (Drupal, Backbone, $) {
'use strict';
Drupal.ckeditor.VisualView = Backbone.View.extend(/** @lends Drupal.ckeditor.VisualView# */{
events: {
'click .ckeditor-toolbar-group-name': 'onGroupNameClick',
'click .ckeditor-groupnames-toggle': 'onGroupNamesToggleClick',
'click .ckeditor-add-new-group button': 'onAddGroupButtonClick'
},
/**
* Backbone View for CKEditor toolbar configuration; visual UX.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
this.listenTo(this.model, 'change:isDirty change:groupNamesVisible', this.render);
// Add a toggle for the button group names.
$(Drupal.theme('ckeditorButtonGroupNamesToggle'))
.prependTo(this.$el.find('#ckeditor-active-toolbar').parent());
this.render();
},
/**
* Render function for rendering the toolbar configuration.
*
* @param {*} model
* Model used for the view.
* @param {string} [value]
* The value that was changed.
* @param {object} changedAttributes
* The attributes that was changed.
*
* @return {Drupal.ckeditor.VisualView}
* The {@link Drupal.ckeditor.VisualView} object.
*/
render: function (model, value, changedAttributes) {
this.insertPlaceholders();
this.applySorting();
// Toggle button group names.
var groupNamesVisible = this.model.get('groupNamesVisible');
// If a button was just placed in the active toolbar, ensure that the
// button group names are visible.
if (changedAttributes && changedAttributes.changes && changedAttributes.changes.isDirty) {
this.model.set({groupNamesVisible: true}, {silent: true});
groupNamesVisible = true;
}
this.$el.find('[data-toolbar="active"]').toggleClass('ckeditor-group-names-are-visible', groupNamesVisible);
this.$el.find('.ckeditor-groupnames-toggle')
.text((groupNamesVisible) ? Drupal.t('Hide group names') : Drupal.t('Show group names'))
.attr('aria-pressed', groupNamesVisible);
return this;
},
/**
* Handles clicks to a button group name.
*
* @param {jQuery.Event} event
* The click event on the button group.
*/
onGroupNameClick: function (event) {
var $group = $(event.currentTarget).closest('.ckeditor-toolbar-group');
Drupal.ckeditor.openGroupNameDialog(this, $group);
event.stopPropagation();
event.preventDefault();
},
/**
* Handles clicks on the button group names toggle button.
*
* @param {jQuery.Event} event
* The click event on the toggle button.
*/
onGroupNamesToggleClick: function (event) {
this.model.set('groupNamesVisible', !this.model.get('groupNamesVisible'));
event.preventDefault();
},
/**
* Prompts the user to provide a name for a new button group; inserts it.
*
* @param {jQuery.Event} event
* The event of the button click.
*/
onAddGroupButtonClick: function (event) {
/**
* Inserts a new button if the openGroupNameDialog function returns true.
*
* @param {bool} success
* A flag that indicates if the user created a new group (true) or
* canceled out of the dialog (false).
* @param {jQuery} $group
* A jQuery DOM fragment that represents the new button group. It has
* not been added to the DOM yet.
*/
function insertNewGroup(success, $group) {
if (success) {
$group.appendTo($(event.currentTarget).closest('.ckeditor-row').children('.ckeditor-toolbar-groups'));
// Focus on the new group.
$group.trigger('focus');
}
}
// Pass in a DOM fragment of a placeholder group so that the new group
// name can be applied to it.
Drupal.ckeditor.openGroupNameDialog(this, $(Drupal.theme('ckeditorToolbarGroup')), insertNewGroup);
event.preventDefault();
},
/**
* Handles jQuery Sortable stop sort of a button group.
*
* @param {jQuery.Event} event
* The event triggered on the group drag.
* @param {object} ui
* A jQuery.ui.sortable argument that contains information about the
* elements involved in the sort action.
*/
endGroupDrag: function (event, ui) {
var view = this;
Drupal.ckeditor.registerGroupMove(this, ui.item, function (success) {
if (!success) {
// Cancel any sorting in the configuration area.
view.$el.find('.ckeditor-toolbar-configuration').find('.ui-sortable').sortable('cancel');
}
});
},
/**
* Handles jQuery Sortable start sort of a button.
*
* @param {jQuery.Event} event
* The event triggered on the group drag.
* @param {object} ui
* A jQuery.ui.sortable argument that contains information about the
* elements involved in the sort action.
*/
startButtonDrag: function (event, ui) {
this.$el.find('a:focus').trigger('blur');
// Show the button group names as soon as the user starts dragging.
this.model.set('groupNamesVisible', true);
},
/**
* Handles jQuery Sortable stop sort of a button.
*
* @param {jQuery.Event} event
* The event triggered on the button drag.
* @param {object} ui
* A jQuery.ui.sortable argument that contains information about the
* elements involved in the sort action.
*/
endButtonDrag: function (event, ui) {
var view = this;
Drupal.ckeditor.registerButtonMove(this, ui.item, function (success) {
if (!success) {
// Cancel any sorting in the configuration area.
view.$el.find('.ui-sortable').sortable('cancel');
}
// Refocus the target button so that the user can continue from a known
// place.
ui.item.find('a').trigger('focus');
});
},
/**
* Invokes jQuery.sortable() on new buttons and groups in a CKEditor config.
*/
applySorting: function () {
// Make the buttons sortable.
this.$el.find('.ckeditor-buttons').not('.ui-sortable').sortable({
// Change this to .ckeditor-toolbar-group-buttons.
connectWith: '.ckeditor-buttons',
placeholder: 'ckeditor-button-placeholder',
forcePlaceholderSize: true,
tolerance: 'pointer',
cursor: 'move',
start: this.startButtonDrag.bind(this),
// Sorting within a sortable.
stop: this.endButtonDrag.bind(this)
}).disableSelection();
// Add the drag and drop functionality to button groups.
this.$el.find('.ckeditor-toolbar-groups').not('.ui-sortable').sortable({
connectWith: '.ckeditor-toolbar-groups',
cancel: '.ckeditor-add-new-group',
placeholder: 'ckeditor-toolbar-group-placeholder',
forcePlaceholderSize: true,
cursor: 'move',
stop: this.endGroupDrag.bind(this)
});
// Add the drag and drop functionality to buttons.
this.$el.find('.ckeditor-multiple-buttons li').draggable({
connectToSortable: '.ckeditor-toolbar-active .ckeditor-buttons',
helper: 'clone'
});
},
/**
* Wraps the invocation of methods to insert blank groups and rows.
*/
insertPlaceholders: function () {
this.insertPlaceholderRow();
this.insertNewGroupButtons();
},
/**
* Inserts a blank row at the bottom of the CKEditor configuration.
*/
insertPlaceholderRow: function () {
var $rows = this.$el.find('.ckeditor-row');
// Add a placeholder row. to the end of the list if one does not exist.
if (!$rows.eq(-1).hasClass('placeholder')) {
this.$el
.find('.ckeditor-toolbar-active')
.children('.ckeditor-active-toolbar-configuration')
.append(Drupal.theme('ckeditorRow'));
}
// Update the $rows variable to include the new row.
$rows = this.$el.find('.ckeditor-row');
// Remove blank rows except the last one.
var len = $rows.length;
$rows.filter(function (index, row) {
// Do not remove the last row.
if (index + 1 === len) {
return false;
}
return $(row).find('.ckeditor-toolbar-group').not('.placeholder').length === 0;
})
// Then get all rows that are placeholders and remove them.
.remove();
},
/**
* Inserts a button in each row that will add a new CKEditor button group.
*/
insertNewGroupButtons: function () {
// Insert an add group button to each row.
this.$el.find('.ckeditor-row').each(function () {
var $row = $(this);
var $groups = $row.find('.ckeditor-toolbar-group');
var $button = $row.find('.ckeditor-add-new-group');
if ($button.length === 0) {
$row.children('.ckeditor-toolbar-groups').append(Drupal.theme('ckeditorNewButtonGroup'));
}
// If a placeholder group exists, make sure it's at the end of the row.
else if (!$groups.eq(-1).hasClass('ckeditor-add-new-group')) {
$button.appendTo($row.children('.ckeditor-toolbar-groups'));
}
});
}
});
})(Drupal, Backbone, jQuery);