Update Composer, update everything

This commit is contained in:
Oliver Davies 2018-11-23 12:29:20 +00:00
parent ea3e94409f
commit dda5c284b6
19527 changed files with 1135420 additions and 351004 deletions

View file

@ -0,0 +1,555 @@
/**
* @file
* CKEditor button and group configuration user interface.
*/
(function($, Drupal, drupalSettings, _) {
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(context) {
// Process the CKEditor configuration fragment once.
const $configurationForm = $(context)
.find('.ckeditor-toolbar-configuration')
.once('ckeditor-configuration');
if ($configurationForm.length) {
const $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.
Drupal.ckeditor.models.Model = new Drupal.ckeditor.Model({
$textarea,
activeEditorConfig: JSON.parse($textarea.val()),
hiddenEditorConfig: drupalSettings.ckeditor.hiddenCKEditorConfig,
});
// Create the configuration Views.
const viewDefaults = {
model: Drupal.ckeditor.models.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(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.
const $configurationForm = $(context)
.find('.ckeditor-toolbar-configuration')
.findOnce('ckeditor-configuration');
if (
$configurationForm.length &&
Drupal.ckeditor.models &&
Drupal.ckeditor.models.Model
) {
const config = Drupal.ckeditor.models.Model.toJSON().activeEditorConfig;
const buttons = Drupal.ckeditor.views.controller.getButtonList(config);
const $activeToolbar = $('.ckeditor-toolbar-configuration').find(
'.ckeditor-toolbar-active',
);
for (let 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(view, $button, callback) {
const $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(view, $group) {
// Remove placeholder classes if necessary.
let $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(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) {
const $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() {
// eslint-disable-next-line no-use-before-define
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.
const 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.
const $ckeditorButtonGroupNameForm = $(
Drupal.theme('ckeditorButtonGroupNameForm'),
);
const 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() {
closeDialog('apply', this);
},
primary: true,
},
{
text: Drupal.t('Cancel'),
click() {
closeDialog('cancel');
},
},
],
open() {
const form = this;
const $form = $(this);
const $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', event => {
// React to enter key press.
if (event.keyCode === 13) {
const $target = $(event.currentTarget);
const data = $target.data('ui-button');
let 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.
let 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(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(context) {
const $context = $(context);
const $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() {
const $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',
(event, action, button) => {
const $pluginSettings = $ckeditorPluginSettings.find(
`[data-ckeditor-buttons~=${button}]`,
);
// No settings for this button.
if ($pluginSettings.length === 0) {
return;
}
const verticalTab = $pluginSettings.data('verticalTab');
const 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() {
let 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

@ -1,51 +1,29 @@
/**
* @file
* CKEditor button and group configuration user interface.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(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.
attach: function attach(context) {
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');
var $textarea = $configurationForm.find('.js-form-item-editor-settings-toolbar-button-groups').hide().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({
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,
model: Drupal.ckeditor.models.Model,
el: $('.ckeditor-toolbar-configuration')
};
Drupal.ckeditor.views = {
@ -56,17 +34,11 @@
};
}
},
detach: function (context, settings, trigger) {
// Early-return if the trigger for detachment is something else than
// unload.
detach: function detach(context, settings, trigger) {
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;
@ -79,48 +51,14 @@
}
};
/**
* 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) {
registerButtonMove: function registerButtonMove(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;
@ -128,33 +66,17 @@
view.isProcessing = true;
Drupal.ckeditor.openGroupNameDialog(view, $group, callback);
}
else {
} 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.
registerGroupMove: function registerGroupMove(view, $group) {
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) {
@ -163,170 +85,90 @@
});
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) {
openGroupNameDialog: function openGroupNameDialog(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]);
$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.removeAttr('aria-label').attr('data-drupal-ckeditor-type', 'group').attr('tabindex', 0).children('.ckeditor-toolbar-group-name').attr('id', groupID).end().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);
$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
buttons: [{
text: Drupal.t('Apply'),
click: function click() {
closeDialog('apply', this);
},
{
text: Drupal.t('Cancel'),
click: function () {
closeDialog('cancel');
}
primary: true
}, {
text: Drupal.t('Cancel'),
click: function click() {
closeDialog('cancel');
}
],
open: function () {
}],
open: function open() {
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();
}
@ -336,7 +178,7 @@
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.', {
@ -345,118 +187,70 @@
}
Drupal.announce(text);
},
close: function (event) {
// Automatically destroy the DOM element that was used for the dialog.
close: function close(event) {
$(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');
$(document.querySelector('.ckeditor-name-toolbar-group').querySelector('input')).attr('value', $group.attr('data-drupal-ckeditor-toolbar-group-name')).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) {
attach: function attach(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.
} else {
$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 + ']');
$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;
if ($pluginSettings.length === 0) {
return;
}
var verticalTab = $pluginSettings.data('verticalTab');
var activeButtons = $pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons');
if (action === 'added') {
activeButtons.push(button);
if (verticalTab) {
verticalTab.tabShow();
} else {
$pluginSettings.show();
}
} else {
activeButtons.splice(activeButtons.indexOf(button), 1);
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 (activeButtons.length === 0) {
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();
}
verticalTab.tabHide();
} else {
$pluginSettings.hide();
}
}
$pluginSettings.data('ckeditorButtonPluginSettingsActiveButtons', activeButtons);
});
}
$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.') + '">';
@ -466,34 +260,15 @@
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, _);
})(jQuery, Drupal, drupalSettings, _);

View file

@ -0,0 +1,50 @@
/**
* @file
* CKEditor 'drupalimage' plugin admin behavior.
*/
(function($, Drupal, drupalSettings) {
/**
* 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() {
$('[data-ckeditor-plugin-id="drupalimage"]').drupalSetSummary(context => {
const root =
'input[name="editor[settings][plugins][drupalimage][image_upload]';
const $status = $(`${root}[status]"]`);
const $maxFileSize = $(`${root}[max_size]"]`);
const $maxWidth = $(`${root}[max_dimensions][width]"]`);
const $maxHeight = $(`${root}[max_dimensions][height]"]`);
const $scheme = $(`${root}[scheme]"]:checked`);
const maxFileSize = $maxFileSize.val()
? $maxFileSize.val()
: $maxFileSize.attr('placeholder');
const maxDimensions =
$maxWidth.val() && $maxHeight.val()
? `(${$maxWidth.val()}x${$maxHeight.val()})`
: '';
if (!$status.is(':checked')) {
return Drupal.t('Uploads disabled');
}
let 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);

View file

@ -1,22 +1,13 @@
/**
* @file
* CKEditor 'drupalimage' plugin admin behavior.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(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 () {
attach: function attach() {
$('[data-ckeditor-plugin-id="drupalimage"]').drupalSetSummary(function (context) {
var root = 'input[name="editor[settings][plugins][drupalimage][image_upload]';
var $status = $(root + '[status]"]');
@ -26,14 +17,17 @@
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() + ')' : '';
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});
output += Drupal.t('Uploads enabled, max size: @size @dimensions', {
'@size': maxFileSize,
'@dimensions': maxDimensions
});
if ($scheme.length) {
output += '<br />' + $scheme.attr('data-label');
}
@ -41,5 +35,4 @@
});
}
};
})(jQuery, Drupal, drupalSettings);
})(jQuery, Drupal, drupalSettings);

View file

@ -0,0 +1,390 @@
/**
* @file
* CKEditor implementation of {@link Drupal.editors} API.
*/
(function(Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) {
/**
* @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(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.
const 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(element, format, trigger) {
const 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 occurred.
* @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(element, callback) {
const editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
editor.on(
'change',
debounce(() => {
callback(editor.getData());
}, 400),
);
// A temporary workaround to control scrollbar appearance when using
// autoGrow event to control editor's height.
// @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed.
editor.on('mode', () => {
const editable = editor.editable();
if (!editable.isInline()) {
editor.on(
'autoGrow',
evt => {
const doc = evt.editor.document;
const scrollable = CKEDITOR.env.quirks
? doc.getBody()
: doc.getDocumentElement();
if (scrollable.$.scrollHeight < scrollable.$.clientHeight) {
scrollable.setStyle('overflow-y', 'hidden');
} else {
scrollable.removeStyle('overflow-y');
}
},
null,
null,
10000,
);
}
});
}
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(element, format, mainToolbarId, floatedToolbarId) {
this._loadExternalPlugins(format);
// Also pass settings that are Drupal-specific.
format.editorSettings.drupal = {
format: format.format,
};
const 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) {
const 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.)
let sourceButtonFound = false;
for (
let i = 0;
!sourceButtonFound && i < settings.toolbar.length;
i++
) {
if (settings.toolbar[i] !== '/') {
for (
let 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(format) {
const externalPlugins = format.editorSettings.drupalExternalPlugins;
// Register and load additional CKEditor plugins as necessary.
if (externalPlugins) {
Object.keys(externalPlugins || {}).forEach(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(editor, url, existingValues, saveCallback, dialogSettings) {
// Locate a suitable place to display our loading indicator.
let $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.
const 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.
const $content = $(
`<div class="ckeditor-dialog-loading"><span style="top: -40px;" class="ckeditor-dialog-loading-link">${Drupal.t(
'Loading...',
)}</span></div>`,
);
$content.appendTo($target);
const ckeditorAjaxDialog = Drupal.ajax({
dialog: dialogSettings,
dialogType: 'modal',
selector: '.ckeditor-dialog-loading-link',
url,
progress: { type: 'throbber' },
submit: {
editor_object: existingValues,
},
});
ckeditorAjaxDialog.execute();
// After a short delay, show "Loading…" message.
window.setTimeout(() => {
$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', (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', (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', (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', (e, dialog, $element) => {
if (Drupal.ckeditor.saveCallback) {
Drupal.ckeditor.saveCallback = null;
}
});
// Formulate a default formula for the maximum autoGrow height.
$(document).on('drupalViewportOffsetChange', () => {
CKEDITOR.config.autoGrow_maxHeight =
0.7 *
(window.innerHeight - displace.offsets.top - displace.offsets.bottom);
});
// Redirect on hash change when the original hash has an associated CKEditor.
function redirectTextareaFragmentToCKEditorInstance() {
const hash = window.location.hash.substr(1);
const element = document.getElementById(hash);
if (element) {
const editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
const id = editor.container.getAttribute('id');
window.location.replace(`#${id}`);
}
}
}
$(window).on(
'hashchange.ckeditor',
redirectTextareaFragmentToCKEditorInstance,
);
// Set autoGrow to make the editor grow the moment it is created.
CKEDITOR.config.autoGrow_onStartup = true;
// Set the CKEditor cache-busting string to the same value as Drupal.
CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp;
if (AjaxCommands) {
/**
* Command to add style sheets to a CKEditor instance.
*
* Works for both iframe and inline CKEditor instances.
*
* @param {Drupal.Ajax} [ajax]
* {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
* @param {object} response
* The response from the Ajax request.
* @param {string} response.editor_id
* The CKEditor instance ID.
* @param {number} [status]
* The XMLHttpRequest status.
*
* @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document
*/
AjaxCommands.prototype.ckeditor_add_stylesheet = function(
ajax,
response,
status,
) {
const editor = CKEDITOR.instances[response.editor_id];
if (editor) {
response.stylesheets.forEach(url => {
editor.document.appendStyleSheet(url);
});
}
};
}
})(
Drupal,
Drupal.debounce,
CKEDITOR,
jQuery,
Drupal.displace,
Drupal.AjaxCommands,
);

View file

@ -1,94 +1,45 @@
/**
* @file
* CKEditor implementation of {@link Drupal.editors} API.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) {
'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) {
attach: function attach(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});
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) {
detach: function detach(element, format, trigger) {
var editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
if (trigger === 'serialize') {
editor.updateElement();
}
else {
} 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) {
onChange: function onChange(element, callback) {
var editor = CKEDITOR.dom.element.get(element).getEditor();
if (editor) {
editor.on('change', debounce(function () {
callback(editor.getData());
}, 400));
// A temporary workaround to control scrollbar appearance when using
// autoGrow event to control editor's height.
// @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed.
editor.on('mode', function () {
var editable = editor.editable();
if (!editable.isInline()) {
@ -98,8 +49,7 @@
if (scrollable.$.scrollHeight < scrollable.$.clientHeight) {
scrollable.setStyle('overflow-y', 'hidden');
}
else {
} else {
scrollable.removeStyle('overflow-y');
}
}, null, null, 10000);
@ -108,35 +58,15 @@
}
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) {
attachInlineEditor: function attachInlineEditor(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',
@ -146,15 +76,13 @@
}
};
// 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';
@ -168,80 +96,39 @@
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) {
_loadExternalPlugins: function _loadExternalPlugins(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], '');
}
}
Object.keys(externalPlugins || {}).forEach(function (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.
openDialog: function openDialog(editor, url, existingValues, saveCallback, dialogSettings) {
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);
@ -250,92 +137,65 @@
dialogType: 'modal',
selector: '.ckeditor-dialog-loading-link',
url: url,
progress: {type: 'throbber'},
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'});
$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 () {
$('.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;
}
});
// Formulate a default formula for the maximum autoGrow height.
$(document).on('drupalViewportOffsetChange', function () {
CKEDITOR.config.autoGrow_maxHeight = 0.7 * (window.innerHeight - displace.offsets.top - displace.offsets.bottom);
});
// Redirect on hash change when the original hash has an associated CKEditor.
function redirectTextareaFragmentToCKEditorInstance() {
var hash = location.hash.substr(1);
var hash = window.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.location.replace('#' + id);
}
}
}
$(window).on('hashchange.ckeditor', redirectTextareaFragmentToCKEditorInstance);
// Set autoGrow to make the editor grow the moment it is created.
CKEDITOR.config.autoGrow_onStartup = true;
// Set the CKEditor cache-busting string to the same value as Drupal.
CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp;
if (AjaxCommands) {
/**
* Command to add style sheets to a CKEditor instance.
*
* Works for both iframe and inline CKEditor instances.
*
* @param {Drupal.Ajax} [ajax]
* {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
* @param {object} response
* The response from the Ajax request.
* @param {string} response.editor_id
* The CKEditor instance ID.
* @param {number} [status]
* The XMLHttpRequest status.
*
* @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document
*/
AjaxCommands.prototype.ckeditor_add_stylesheet = function (ajax, response, status) {
var editor = CKEDITOR.instances[response.editor_id];
@ -346,5 +206,4 @@
}
};
}
})(Drupal, Drupal.debounce, CKEDITOR, jQuery, Drupal.displace, Drupal.AjaxCommands);
})(Drupal, Drupal.debounce, CKEDITOR, jQuery, Drupal.displace, Drupal.AjaxCommands);

View file

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

View file

@ -1,16 +1,16 @@
/**
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal) {
'use strict';
/**
* Provides the summary for the "language" plugin settings vertical tab.
*/
Drupal.behaviors.ckeditorLanguageSettingsSummary = {
attach: function () {
attach: function attach() {
$('#edit-editor-settings-plugins-language').drupalSetSummary(function (context) {
return $('#edit-editor-settings-plugins-language-language-list-type option:selected').text();
});
}
};
})(jQuery, Drupal);
})(jQuery, Drupal);

View file

@ -0,0 +1,132 @@
/**
* @file
* CKEditor StylesCombo admin behavior.
*/
(function($, Drupal, drupalSettings, _) {
/**
* 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(context) {
const $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.)
const $ckeditorActiveToolbar = $context
.find('.ckeditor-toolbar-configuration')
.find('.ckeditor-toolbar-active');
let previousStylesSet =
drupalSettings.ckeditor.hiddenCKEditorConfig.stylesSet;
const that = this;
$context
.find('[name="editor[settings][plugins][stylescombo][styles]"]')
.on('blur.ckeditorStylesComboSettings', function() {
const styles = $.trim($(this).val());
const stylesSet = that._generateStylesSetSetting(styles);
if (!_.isEqual(previousStylesSet, stylesSet)) {
previousStylesSet = stylesSet;
$ckeditorActiveToolbar.trigger('CKEditorPluginSettingsChanged', [
{ 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(styles) {
const stylesSet = [];
styles = styles.replace(/\r/g, '\n');
const lines = styles.split('\n');
for (let i = 0; i < lines.length; i++) {
const 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.
const parts = style.split('|');
const selector = parts[0];
const label = parts[1];
const classes = selector.split('.');
const 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,
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() {
$('[data-ckeditor-plugin-id="stylescombo"]').drupalSetSummary(context => {
const styles = $.trim(
$(
'[data-drupal-selector="edit-editor-settings-plugins-stylescombo-styles"]',
).val(),
);
if (styles.length === 0) {
return Drupal.t('No styles configured');
}
const count = $.trim(styles).split('\n').length;
return Drupal.t('@count styles configured', { '@count': count });
});
},
};
})(jQuery, Drupal, drupalSettings, _);

View file

@ -1,69 +1,28 @@
/**
* @file
* CKEditor StylesCombo admin behavior.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(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) {
attach: function attach(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 $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}
]);
}
});
$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) {
_generateStylesSetSetting: function _generateStylesSetSetting(styles) {
var stylesSet = [];
styles = styles.replace(/\r/g, '\n');
@ -71,28 +30,22 @@
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(' ')},
attributes: { class: classes.join(' ') },
element: element,
name: label
});
@ -102,27 +55,17 @@
}
};
/**
* 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 () {
attach: function attach() {
$('[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});
}
var count = $.trim(styles).split('\n').length;
return Drupal.t('@count styles configured', { '@count': count });
});
}
};
})(jQuery, Drupal, drupalSettings, _);
})(jQuery, Drupal, drupalSettings, _);

View file

@ -0,0 +1,73 @@
/**
* @file
* A Backbone Model for the state of a CKEditor toolbar configuration .
*/
(function(Drupal, Backbone) {
/**
* 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() {
// Push the settings into the textarea.
this.get('$textarea').val(
JSON.stringify(this.get('activeEditorConfig')),
);
},
},
);
})(Drupal, Backbone);

View file

@ -1,75 +1,30 @@
/**
* @file
* A Backbone Model for the state of a CKEditor toolbar configuration .
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(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.
*/
Drupal.ckeditor.Model = Backbone.Model.extend({
defaults: {
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.
sync: function sync() {
this.get('$textarea').val(JSON.stringify(this.get('activeEditorConfig')));
}
});
})(Drupal, Backbone);
})(Drupal, Backbone);

View file

@ -0,0 +1,399 @@
/**
* @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) {
/**
* 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) {
const widget = editor.widgets.focused;
if (widget && widget.name === 'image') {
return widget;
}
return null;
}
/**
* 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) {
const 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) {
const 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();
});
}
CKEDITOR.plugins.add('drupalimage', {
requires: 'image2',
icons: 'drupalimage',
hidpi: true,
beforeInit(editor) {
// Override the image2 widget definition to require and handle the
// additional data-entity-type and data-entity-uuid attributes.
editor.on('widgetDefinition', event => {
const 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.
const 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.
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
const originalGetClasses = widgetDefinition.getClasses;
widgetDefinition.getClasses = function() {
const classes = originalGetClasses.call(this);
const captionedClasses = (
this.editor.config.image2_captionedClass || ''
).split(/\s+/);
if (captionedClasses.length && classes) {
for (let 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) {
const dialogValues = {};
const map = widgetDefinition._mapDataToDialog;
Object.keys(widgetDefinition._mapDataToDialog).forEach(key => {
dialogValues[map[key]] = data[key];
});
return dialogValues;
};
// Protected; the inverse of _dataToDialogValues.
widgetDefinition._dialogValuesToData = function(dialogReturnValues) {
const data = {};
const map = widgetDefinition._mapDataToDialog;
Object.keys(widgetDefinition._mapDataToDialog).forEach(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) {
const 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.
const container = widget.wrapper.getParent(true);
const 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.
const 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(() => {
// (Re-)focus the widget.
widget.focus();
// Save snapshot for undo support.
editor.fire('saveSnapshot');
});
return widget;
};
};
const 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', event => {
const widget = event.data;
if (widget.name !== 'image') {
return;
}
widget.on('edit', 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(editor, data) {
const 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(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;
};
// Expose an API for other plugins to interact with drupalimage widgets.
CKEDITOR.plugins.drupalimage = {
getFocusedWidget,
};
})(jQuery, Drupal, CKEDITOR);

View file

@ -1,61 +1,77 @@
/**
* @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
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, CKEDITOR) {
function getFocusedWidget(editor) {
var widget = editor.widgets.focused;
'use strict';
if (widget && widget.name === 'image') {
return widget;
}
return null;
}
function linkCommandIntegrator(editor) {
if (!editor.plugins.drupallink) {
return;
}
editor.getCommand('drupalunlink').on('exec', function (evt) {
var widget = getFocusedWidget(editor);
if (!widget || !widget.parts.link) {
return;
}
widget.setData('link', null);
this.refresh(editor, editor.elementPath());
evt.cancel();
});
editor.getCommand('drupalunlink').on('refresh', function (evt) {
var widget = getFocusedWidget(editor);
if (!widget) {
return;
}
this.setState(widget.data.link || widget.wrapper.getAscendant('a') ? CKEDITOR.TRISTATE_OFF : CKEDITOR.TRISTATE_DISABLED);
evt.cancel();
});
}
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.
beforeInit: function beforeInit(editor) {
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
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: {
@ -64,10 +80,6 @@
}
});
// 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'] = '';
@ -75,44 +87,27 @@
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']) {
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);
@ -129,21 +124,15 @@
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',
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;
@ -153,7 +142,6 @@
return dialogValues;
};
// Protected; the inverse of _dataToDialogValues.
widgetDefinition._dialogValuesToData = function (dialogReturnValues) {
var data = {};
var map = widgetDefinition._mapDataToDialog;
@ -165,44 +153,31 @@
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');
});
@ -214,19 +189,12 @@
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;
@ -235,30 +203,23 @@
}
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},
modes: { wysiwyg: 1 },
canUndo: true,
exec: function (editor, data) {
exec: function exec(editor, data) {
var dialogSettings = {
title: data.dialogTitle,
dialogClass: 'editor-image-dialog'
@ -267,24 +228,19 @@
}
});
// 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) {
afterInit: function afterInit(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;
};
@ -292,80 +248,7 @@
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);
})(jQuery, Drupal, CKEDITOR);

View file

@ -0,0 +1,347 @@
/**
* @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) {
/**
* 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;
}
let found = null;
element.forEach(el => {
if (el.name === name) {
found = el;
// Stop here.
return false;
}
}, CKEDITOR.NODE_ELEMENT);
return found;
}
CKEDITOR.plugins.add('drupalimagecaption', {
requires: 'drupalimage',
beforeInit(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.
const placeholderText =
editor.config.drupalImageCaption_captionPlaceholderText;
// Override the image2 widget definition to handle the additional
// data-align and data-caption attributes.
editor.on(
'widgetDefinition',
event => {
const widgetDefinition = event.data;
if (widgetDefinition.name !== 'image') {
return;
}
// Only perform the downcasting/upcasting for to the enabled filters.
const captionFilterEnabled =
editor.config.drupalImageCaption_captionFilterEnabled;
const 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.
const 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.
const originalDowncast = widgetDefinition.downcast;
widgetDefinition.downcast = function(element) {
const img = findElementByName(element, 'img');
originalDowncast.call(this, img);
const caption = this.editables.caption;
const captionHtml = caption && caption.getData();
const 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;
}
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.
const 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.
if (element.attributes['data-cke-realelement']) {
return;
}
element = originalUpcast.call(this, element, data);
const attrs = element.attributes;
if (element.parent.name === 'a') {
element = element.parent;
}
let retElement = element;
let 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) {
let index = element.getIndex();
const splitBefore = index > 0;
const 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) {
const 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)
) {
const 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.
const originalCreateDialogSaveCallback =
widgetDefinition._createDialogSaveCallback;
widgetDefinition._createDialogSaveCallback = function(
editor,
widget,
) {
const 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;
const 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.
const 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(editor) {
const disableButtonIfOnWidget = function(evt) {
const 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
) {
let cmd;
const commands = [
'justifyleft',
'justifycenter',
'justifyright',
'justifyblock',
];
for (let n = 0; n < commands.length; n++) {
cmd = editor.getCommand(commands[n]);
cmd.contextSensitive = 1;
cmd.on('refresh', disableButtonIfOnWidget, null, null, 4);
}
}
},
});
})(CKEDITOR);

View file

@ -1,46 +1,44 @@
/**
* @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
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (CKEDITOR) {
function findElementByName(element, name) {
if (element.name === name) {
return element;
}
'use strict';
var found = null;
element.forEach(function (el) {
if (el.name === name) {
found = el;
return false;
}
}, CKEDITOR.NODE_ELEMENT);
return found;
}
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).
beforeInit: function beforeInit(editor) {
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]'
@ -50,10 +48,6 @@
}
}, 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'] = '';
@ -61,15 +55,8 @@
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');
@ -80,8 +67,6 @@
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;
}
@ -92,28 +77,20 @@
}
}
// If img is wrapped with a link, we want to return that link.
if (img.parent.name === 'a') {
return img.parent;
}
else {
return img;
}
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']) {
if (element.attributes['data-cke-realelement']) {
return;
}
@ -125,10 +102,8 @@
}
var retElement = element;
var caption;
var caption = void 0;
// 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'];
@ -143,9 +118,6 @@
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;
@ -163,78 +135,52 @@
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;
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',
align: 'data-align',
'data-caption': 'data-caption',
'hasCaption': 'hasCaption'
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));
@ -242,12 +188,10 @@
}
};
};
// Low priority to ensure drupalimage's event handler runs first.
}, null, null, 20);
},
afterInit: function (editor) {
var disableButtonIfOnWidget = function (evt) {
afterInit: function afterInit(editor) {
var disableButtonIfOnWidget = function disableButtonIfOnWidget(evt) {
var widget = editor.widgets.focused;
if (widget && widget.name === 'image') {
this.setState(CKEDITOR.TRISTATE_DISABLED);
@ -255,9 +199,8 @@
}
};
// Disable alignment buttons if the align filter is not enabled.
if (editor.plugins.justify && !editor.config.drupalImageCaption_alignFilterEnabled) {
var cmd;
var cmd = void 0;
var commands = ['justifyleft', 'justifycenter', 'justifyright', 'justifyblock'];
for (var n = 0; n < commands.length; n++) {
cmd = editor.getCommand(commands[n]);
@ -267,35 +210,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);
})(CKEDITOR);

View file

@ -0,0 +1,334 @@
/**
* @file
* Drupal Link plugin.
*
* @ignore
*/
(function($, Drupal, drupalSettings, CKEDITOR) {
function parseAttributes(editor, element) {
const parsedAttributes = {};
const domElement = element.$;
let attribute;
let attributeName;
for (
let 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) {
const set = {};
Object.keys(data || {}).forEach(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.
const removed = {};
Object.keys(set).forEach(s => {
delete removed[s];
});
return {
set,
removed: CKEDITOR.tools.objectKeys(removed),
};
}
/**
* 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) {
const selection = editor.getSelection();
const selectedElement = selection.getSelectedElement();
if (selectedElement && selectedElement.is('a')) {
return selectedElement;
}
const range = selection.getRanges(true)[0];
if (range) {
range.shrink(CKEDITOR.SHRINK_TEXT);
return editor.elementPath(range.getCommonAncestor()).contains('a', 1);
}
return null;
}
CKEDITOR.plugins.add('drupallink', {
icons: 'drupallink,drupalunlink',
hidpi: true,
init(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(editor) {
const drupalImageUtils = CKEDITOR.plugins.drupalimage;
const focusedImageWidget =
drupalImageUtils && drupalImageUtils.getFocusedWidget(editor);
let linkElement = getSelectedLink(editor);
// Set existing values based on selected element.
let 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.
const 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) {
const selection = editor.getSelection();
const 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.
const 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.
const 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) {
Object.keys(returnValues.attributes || {}).forEach(attrName => {
// Update the property if a value is specified.
if (returnValues.attributes[attrName].length > 0) {
const 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.
const 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(editor) {
const style = new CKEDITOR.style({
element: 'a',
type: CKEDITOR.STYLE_INLINE,
alwaysRemoveElement: 1,
});
editor.removeStyle(style);
},
refresh(editor, path) {
const 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', evt => {
const 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((element, selection) => {
if (!element || element.isReadOnly()) {
return null;
}
const anchor = getSelectedLink(editor);
if (!anchor) {
return null;
}
let menu = {};
if (anchor.getAttribute('href') && anchor.getChildCount()) {
menu = {
link: CKEDITOR.TRISTATE_OFF,
unlink: CKEDITOR.TRISTATE_OFF,
};
}
return menu;
});
}
},
});
// 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

@ -1,33 +1,28 @@
/**
* @file
* Drupal Link plugin.
*
* @ignore
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, drupalSettings, CKEDITOR) {
'use strict';
function parseAttributes(editor, element) {
var parsedAttributes = {};
var domElement = element.$;
var attribute;
var attributeName;
var attribute = void 0;
var attributeName = void 0;
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+/, ''));
}
@ -37,23 +32,16 @@
function getAttributes(editor, data) {
var set = {};
for (var attributeName in data) {
if (data.hasOwnProperty(attributeName)) {
set[attributeName] = data[attributeName];
}
}
Object.keys(data || {}).forEach(function (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];
}
}
Object.keys(set).forEach(function (s) {
delete removed[s];
});
return {
set: set,
@ -61,12 +49,27 @@
};
}
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;
}
CKEDITOR.plugins.add('drupallink', {
icons: 'drupallink,drupalunlink',
hidpi: true,
init: function (editor) {
// Add the commands for link and unlink.
init: function init(editor) {
editor.addCommand('drupallink', {
allowedContent: {
a: {
@ -82,28 +85,21 @@
href: ''
}
}),
modes: {wysiwyg: 1},
modes: { wysiwyg: 1 },
canUndo: true,
exec: function (editor) {
exec: function exec(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);
}
} 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.
var saveCallback = function saveCallback(returnValues) {
if (focusedImageWidget) {
focusedImageWidget.setData('link', CKEDITOR.tools.extend(returnValues.attributes, focusedImageWidget.data.link));
editor.fire('saveSnapshot');
@ -112,58 +108,45 @@
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});
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.
} else if (linkElement) {
Object.keys(returnValues.attributes || {}).forEach(function (attrName) {
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);
}
}
} 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);
}
});
@ -176,25 +159,26 @@
href: ''
}
}),
exec: function (editor) {
var style = new CKEDITOR.style({element: 'a', type: CKEDITOR.STYLE_INLINE, alwaysRemoveElement: 1});
exec: function exec(editor) {
var style = new CKEDITOR.style({
element: 'a',
type: CKEDITOR.STYLE_INLINE,
alwaysRemoveElement: 1
});
editor.removeStyle(style);
},
refresh: function (editor, path) {
refresh: function refresh(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 {
} 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'),
@ -217,7 +201,6 @@
}
});
// If the "menu" plugin is loaded, register the menu items.
if (editor.addMenuItems) {
editor.addMenuItems({
link: {
@ -236,7 +219,6 @@
});
}
// If the "contextmenu" plugin is loaded, register the listeners.
if (editor.contextMenu) {
editor.contextMenu.addListener(function (element, selection) {
if (!element || element.isReadOnly()) {
@ -249,7 +231,10 @@
var menu = {};
if (anchor.getAttribute('href') && anchor.getChildCount()) {
menu = {link: CKEDITOR.TRISTATE_OFF, unlink: CKEDITOR.TRISTATE_OFF};
menu = {
link: CKEDITOR.TRISTATE_OFF,
unlink: CKEDITOR.TRISTATE_OFF
};
}
return menu;
});
@ -257,48 +242,8 @@
}
});
/**
* 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);
})(jQuery, Drupal, drupalSettings, CKEDITOR);

View file

@ -0,0 +1,266 @@
/**
* @file
* A Backbone View that provides the aural view of CKEditor toolbar
* configuration.
*/
(function(Drupal, Backbone, $) {
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() {
// 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(model, isDirty) {
// Announce the position of a button or group after the model has been
// updated.
if (!isDirty) {
const item = document.activeElement || null;
if (item) {
const $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(event) {
event.stopPropagation();
const $originalTarget = $(event.target);
const $currentTarget = $(event.currentTarget);
const $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($group) {
const $groups = $group.parent().children();
const $row = $group.closest('.ckeditor-row');
const $rows = $row.parent().children();
const position = $groups.index($group) + 1;
const positionCount = $groups.not('.placeholder').length;
const row = $rows.index($row) + 1;
const rowCount = $rows.not('.placeholder').length;
let 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($button) {
const $row = $button.closest('.ckeditor-row');
const $rows = $row.parent().children();
const $buttons = $button.closest('.ckeditor-buttons').children();
const $group = $button.closest('.ckeditor-toolbar-group');
const $groups = $group.parent().children();
const groupPosition = $groups.index($group) + 1;
const groupPositionCount = $groups.not('.placeholder').length;
const position = $buttons.index($button) + 1;
const positionCount = $buttons.length;
const row = $rows.index($row) + 1;
const 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'.
const type =
$button.attr('data-drupal-ckeditor-type') === 'separator'
? ''
: Drupal.t('button');
let 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(event) {
const $link = $(event.currentTarget);
const $button = $link.parent();
const enabled = $button.closest('.ckeditor-toolbar-active').length > 0;
let 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(event) {
const $link = $(event.currentTarget);
const $button = $link.parent();
const enabled = $button.closest('.ckeditor-toolbar-active').length > 0;
let 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

@ -1,18 +1,12 @@
/**
* @file
* A Backbone View that provides the aural view of CKEditor toolbar
* configuration.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone, $) {
'use strict';
Drupal.ckeditor.AuralView = Backbone.View.extend(/** @lends Drupal.ckeditor.AuralView# */{
/**
* @type {object}
*/
Drupal.ckeditor.AuralView = Backbone.View.extend({
events: {
'click .ckeditor-buttons a': 'announceButtonHelp',
'click .ckeditor-multiple-buttons a': 'announceSeparatorHelp',
@ -21,52 +15,23 @@
'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.
initialize: function initialize() {
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.
announceMove: function announceMove(model, isDirty) {
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')) {
} 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) {
onFocus: function onFocus(event) {
event.stopPropagation();
var $originalTarget = $(event.target);
@ -74,19 +39,11 @@
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')) {
} 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) {
announceButtonGroupPosition: function announceButtonGroupPosition($group) {
var $groups = $group.parent().children();
var $row = $group.closest('.ckeditor-row');
var $rows = $row.parent().children();
@ -101,22 +58,14 @@
'@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) {
announceButtonPosition: function announceButtonPosition($button) {
var $row = $button.closest('.ckeditor-row');
var $rows = $row.parent().children();
var $buttons = $button.closest('.ckeditor-buttons').children();
@ -128,12 +77,10 @@
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.
var type = $button.attr('data-drupal-ckeditor-type') === 'separator' ? '' : Drupal.t('button');
var text = void 0;
if ($button.closest('.ckeditor-toolbar-disabled').length > 0) {
text = Drupal.t('@name @type.', {
'@name': $button.children().attr('aria-label'),
@ -142,45 +89,34 @@
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');
}
},
} 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
});
/**
* Provides help information when a button is clicked.
*
* @param {jQuery.Event} event
* The click event for the button click.
*/
announceButtonHelp: function (event) {
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 (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');
}
},
announceButtonHelp: function announceButtonHelp(event) {
var $link = $(event.currentTarget);
var $button = $link.parent();
var enabled = $button.closest('.ckeditor-toolbar-active').length > 0;
var message;
var message = void 0;
if (enabled) {
message = Drupal.t('The "@name" button is currently enabled.', {
@ -188,8 +124,7 @@
});
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 {
} else {
message = Drupal.t('The "@name" button is currently disabled.', {
'@name': $link.attr('aria-label')
});
@ -198,26 +133,18 @@
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) {
announceSeparatorHelp: function announceSeparatorHelp(event) {
var $link = $(event.currentTarget);
var $button = $link.parent();
var enabled = $button.closest('.ckeditor-toolbar-active').length > 0;
var message;
var message = void 0;
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 {
} 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')
@ -229,5 +156,4 @@
event.preventDefault();
}
});
})(Drupal, Backbone, jQuery);
})(Drupal, Backbone, jQuery);

View file

@ -0,0 +1,420 @@
/**
* @file
* A Backbone View acting as a controller for CKEditor toolbar configuration.
*/
(function($, Drupal, Backbone, CKEDITOR, _) {
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() {
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(model, isDirty, options) {
if (isDirty) {
const currentConfig = this.model.get('activeEditorConfig');
// Process the rows.
const rows = [];
this.$el
.find('.ckeditor-active-toolbar-configuration')
.children('.ckeditor-row')
.each(function() {
const groups = [];
// Process the button groups.
$(this)
.find('.ckeditor-toolbar-group')
.each(function() {
const $group = $(this);
const $buttons = $group.find('.ckeditor-button');
if ($buttons.length) {
const 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) {
const prev = this.getButtonList(currentConfig);
const 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(CKEditorConfig, callback) {
const getProperties = function(CKEPropertiesList) {
return _.isObject(CKEPropertiesList) ? _.keys(CKEPropertiesList) : [];
};
const convertCKERulesToEditorFeature = function(
feature,
CKEFeatureRules,
) {
for (let i = 0; i < CKEFeatureRules.length; i++) {
const CKERule = CKEFeatureRules[i];
const rule = new Drupal.EditorFeatureHTMLRule();
// Tags.
const 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::buildConfigurationForm().
const hiddenCKEditorID = 'ckeditor-hidden';
if (CKEDITOR.instances[hiddenCKEditorID]) {
CKEDITOR.instances[hiddenCKEditorID].destroy(true);
}
// Load external plugins, if any.
const hiddenEditorConfig = this.model.get('hiddenEditorConfig');
if (hiddenEditorConfig.drupalExternalPlugins) {
const externalPlugins = hiddenEditorConfig.drupalExternalPlugins;
Object.keys(externalPlugins || {}).forEach(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', e => {
if (e.editor.name === hiddenCKEditorID) {
// First collect all CKEditor allowedContent rules.
const CKEFeatureRulesMap = {};
const rules = e.editor.filter.allowedContent;
let rule;
let name;
for (let 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()
const features = {};
const buttonsToFeatures = {};
Object.keys(CKEFeatureRulesMap).forEach(featureName => {
const feature = new Drupal.EditorFeature(featureName);
convertCKERulesToEditorFeature(
feature,
CKEFeatureRulesMap[featureName],
);
features[featureName] = feature;
const 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(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.
let 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();
}
const 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(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".
let existingButtons = [];
// Loop through each button group after flattening the groups from the
// toolbar row arrays.
const buttonGroups = _.flatten(this.model.get('activeEditorConfig'));
for (let i = 0; i < buttonGroups.length; i++) {
// Pull the button names from each toolbar button group.
const buttons = buttonGroups[i].items;
for (let 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 (let n = 0; n < existingButtons.length; n++) {
const button = existingButtons[n];
const 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($ckeditorToolbar) {
const view = this;
const hiddenEditorConfig = this.model.get('hiddenEditorConfig');
const getFeatureForButton = this.getFeatureForButton.bind(this);
const 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',
(event, action, button) => {
const 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.
const 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',
(event, settingsChanges) => {
// Update hidden CKEditor configuration.
Object.keys(settingsChanges || {}).forEach(key => {
hiddenEditorConfig[key] = settingsChanges[key];
});
// Retrieve features for the updated hidden CKEditor configuration.
getCKEditorFeatures(hiddenEditorConfig, features => {
// Trigger a standardized text editor configuration event for each
// feature that was modified by the configuration changes.
const featuresMetadata = view.model.get('featuresMetadata');
Object.keys(features || {}).forEach(name => {
const 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(config) {
const buttons = [];
// Remove the rows.
config = _.flatten(config);
// Loop through the button groups and pull out the buttons.
config.forEach(group => {
group.items.forEach(button => {
buttons.push(button);
});
});
// Remove the dividing elements if any.
return _.without(buttons, '-');
},
},
);
})(jQuery, Drupal, Backbone, CKEDITOR, _);

View file

@ -1,175 +1,108 @@
/**
* @file
* A Backbone View acting as a controller for CKEditor toolbar configuration.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, Backbone, CKEDITOR, _) {
'use strict';
Drupal.ckeditor.ControllerView = Backbone.View.extend(/** @lends Drupal.ckeditor.ControllerView# */{
/**
* @type {object}
*/
Drupal.ckeditor.ControllerView = Backbone.View.extend({
events: {},
/**
* Backbone View acting as a controller for CKEditor toolbar configuration.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
initialize: function initialize() {
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) {
parseEditorDOM: function parseEditorDOM(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.$el.find('.ckeditor-active-toolbar-configuration').children('.ckeditor-row').each(function () {
var 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]
]);
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) : [];
getCKEditorFeatures: function getCKEditorFeatures(CKEditorConfig, callback) {
var getProperties = function getProperties(CKEPropertiesList) {
return _.isObject(CKEPropertiesList) ? _.keys(CKEPropertiesList) : [];
};
var convertCKERulesToEditorFeature = function (feature, CKEFeatureRules) {
var convertCKERulesToEditorFeature = function convertCKERulesToEditorFeature(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.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::buildConfigurationForm().
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], '');
}
}
Object.keys(externalPlugins || {}).forEach(function (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;
var rule = void 0;
var name = void 0;
for (var i = 0; i < rules.length; i++) {
rule = rules[i];
name = rule.featureName || ':(';
@ -179,50 +112,29 @@
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;
}
Object.keys(CKEFeatureRulesMap).forEach(function (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.
getFeatureForButton: function getFeatureForButton(button) {
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();
}
@ -233,151 +145,86 @@
}
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) {
disableFeaturesDisallowedByFilters: function disableFeaturesDisallowedByFilters(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});
} else {
$('.ckeditor-toolbar-active li[data-drupal-ckeditor-button-name="' + button + '"]').detach().appendTo('.ckeditor-toolbar-disabled > .ckeditor-toolbar-available > ul');
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) {
broadcastConfigurationChanges: function broadcastConfigurationChanges($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);
$ckeditorToolbar.find('.ckeditor-toolbar-active').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;
}
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);
});
var configEvent = action === 'added' ? 'addedFeature' : 'removedFeature';
Drupal.editorConfiguration[configEvent](feature);
}).on('CKEditorPluginSettingsChanged.ckeditorAdmin', function (event, settingsChanges) {
Object.keys(settingsChanges || {}).forEach(function (key) {
hiddenEditorConfig[key] = settingsChanges[key];
});
},
/**
* 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) {
getCKEditorFeatures(hiddenEditorConfig, function (features) {
var featuresMetadata = view.model.get('featuresMetadata');
Object.keys(features || {}).forEach(function (name) {
var feature = features[name];
if (featuresMetadata.hasOwnProperty(name) && !_.isEqual(featuresMetadata[name], feature)) {
Drupal.editorConfiguration.modifiedFeature(feature);
}
});
view.model.set('featuresMetadata', features);
});
});
},
getButtonList: function getButtonList(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, _);
})(jQuery, Drupal, Backbone, CKEDITOR, _);

View file

@ -0,0 +1,312 @@
/**
* @file
* Backbone View providing the aural view of CKEditor keyboard UX configuration.
*/
(function($, Drupal, Backbone, _) {
Drupal.ckeditor.KeyboardView = Backbone.View.extend(
/** @lends Drupal.ckeditor.KeyboardView# */ {
/**
* Backbone View for CKEditor toolbar configuration; keyboard UX.
*
* @constructs
*
* @augments Backbone.View
*/
initialize() {
// 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() {},
/**
* Handles keypresses on a CKEditor configuration button.
*
* @param {jQuery.Event} event
* The keypress event triggered.
*/
onPressButton(event) {
const upDownKeys = [
38, // Up arrow.
63232, // Safari up arrow.
40, // Down arrow.
63233, // Safari down arrow.
];
const 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) {
let view = this;
let $target = $(event.currentTarget);
let $button = $target.parent();
const $container = $button.parent();
let $group = $button.closest('.ckeditor-toolbar-group');
let $row;
const containerType = $container.data(
'drupal-ckeditor-button-sorting',
);
const $availableButtons = this.$el.find(
'[data-drupal-ckeditor-button-sorting="source"]',
);
const $activeButtons = this.$el.find('.ckeditor-toolbar-active');
// The current location of the button, just in case it needs to be put
// back.
const $originalGroup = $group;
let 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.
const $siblings = $container.children();
const 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, 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(event) {
const upDownKeys = [
38, // Up arrow.
63232, // Safari up arrow.
40, // Down arrow.
63233, // Safari down arrow.
];
const 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) {
const 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(() => {
Drupal.ckeditor.openGroupNameDialog(view, $(event.currentTarget));
}, 0);
event.preventDefault();
event.stopPropagation();
}
// Respond to direction key presses.
if (_.indexOf(_.union(upDownKeys, leftRightKeys), event.keyCode) > -1) {
const $group = $(event.currentTarget);
const $container = $group.parent();
const $siblings = $container.children();
let index;
let 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 {
const $rowChildElement = $container
.closest('.ckeditor-row')
.prev()
.find('.ckeditor-toolbar-groups')
.children()
.eq(-1);
$group.insertBefore($rowChildElement);
}
}
// 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

@ -1,178 +1,98 @@
/**
* @file
* Backbone View providing the aural view of CKEditor keyboard UX configuration.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(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.
Drupal.ckeditor.KeyboardView = Backbone.View.extend({
initialize: function initialize() {
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));
},
render: function render() {},
onPressButton: function onPressButton(event) {
var upDownKeys = [38, 63232, 40, 63233];
var leftRightKeys = [37, 63234, 39, 63235];
/**
* @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;
var $row = void 0;
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.
var $originalGroup = $group;
var dir = void 0;
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.
} else if (containerType === 'target') {
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);
} else {
$group = $container.parent().prev();
if ($group.length > 0) {
$group.find('.ckeditor-toolbar-group-buttons').append($button);
} else {
$container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-group').not('.placeholder').find('.ckeditor-toolbar-group-buttons').eq(-1).append($button);
}
}
} else if (_.indexOf([39, 63235], event.keyCode) > -1) {
if (index < $siblings.length - 1) {
$button.insertAfter($container.children().eq(index + 1));
} else {
$container.parent().next().find('.ckeditor-toolbar-group-buttons').prepend($button);
}
}
} else if (_.indexOf(upDownKeys, event.keyCode) > -1) {
dir = _.indexOf([38, 63232], event.keyCode) > -1 ? 'prev' : 'next';
$row = $container.closest('.ckeditor-row')[dir]();
if (dir === 'prev' && $row.length === 0) {
if ($button.data('drupal-ckeditor-type') === 'separator') {
$button.off().remove();
$activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).children().eq(0).children().trigger('focus');
} else {
$availableButtons.prepend($button);
}
} else {
$row.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($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);
}
} else if (containerType === 'dividers') {
if (_.indexOf([40, 63233], event.keyCode) > -1) {
$button = $button.clone(true);
$activeButtons.find('.ckeditor-toolbar-group-buttons').eq(0).prepend($button);
$target = $button.children();
}
}
// 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.
Drupal.ckeditor.registerButtonMove(this, $button, function (result) {
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.
} else {
view.$el.find('.ui-sortable').sortable('refresh');
}
$target.trigger('focus');
});
@ -180,34 +100,13 @@
event.stopPropagation();
}
},
onPressGroup: function onPressGroup(event) {
var upDownKeys = [38, 63232, 40, 63233];
var leftRightKeys = [37, 63234, 39, 63235];
/**
* 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);
@ -215,45 +114,34 @@
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.
var index = void 0;
var dir = void 0;
if (_.indexOf(leftRightKeys, event.keyCode) > -1) {
index = $siblings.index($group);
// Move left between sibling groups.
if ((_.indexOf([37, 63234], event.keyCode) > -1)) {
if (_.indexOf([37, 63234], event.keyCode) > -1) {
if (index > 0) {
$group.insertBefore($siblings.eq(index - 1));
} else {
var $rowChildElement = $container.closest('.ckeditor-row').prev().find('.ckeditor-toolbar-groups').children().eq(-1);
$group.insertBefore($rowChildElement);
}
} else if (_.indexOf([39, 63235], event.keyCode) > -1) {
if (!$siblings.eq(index + 1).hasClass('placeholder')) {
$group.insertAfter($container.children().eq(index + 1));
} else {
$container.closest('.ckeditor-row').next().find('.ckeditor-toolbar-groups').prepend($group);
}
}
// 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));
}
} 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);
}
// 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');
@ -262,5 +150,4 @@
}
}
});
})(jQuery, Drupal, Backbone, _);
})(jQuery, Drupal, Backbone, _);

View file

@ -0,0 +1,315 @@
/**
* @file
* A Backbone View that provides the visual UX view of CKEditor toolbar
* configuration.
*/
(function(Drupal, Backbone, $) {
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() {
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(model, value, changedAttributes) {
this.insertPlaceholders();
this.applySorting();
// Toggle button group names.
let 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(event) {
const $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(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(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(event, ui) {
const view = this;
Drupal.ckeditor.registerGroupMove(this, ui.item, 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(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(event, ui) {
const view = this;
Drupal.ckeditor.registerButtonMove(this, ui.item, 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() {
// 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() {
this.insertPlaceholderRow();
this.insertNewGroupButtons();
},
/**
* Inserts a blank row at the bottom of the CKEditor configuration.
*/
insertPlaceholderRow() {
let $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.
const len = $rows.length;
$rows
.filter((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() {
// Insert an add group button to each row.
this.$el.find('.ckeditor-row').each(function() {
const $row = $(this);
const $groups = $row.find('.ckeditor-toolbar-group');
const $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);

View file

@ -1,204 +1,99 @@
/**
* @file
* A Backbone View that provides the visual UX view of CKEditor toolbar
* configuration.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, Backbone, $) {
'use strict';
Drupal.ckeditor.VisualView = Backbone.View.extend(/** @lends Drupal.ckeditor.VisualView# */{
Drupal.ckeditor.VisualView = Backbone.View.extend({
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 () {
initialize: function initialize() {
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());
$(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) {
render: function render(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});
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);
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) {
onGroupNameClick: function onGroupNameClick(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) {
onGroupNamesToggleClick: function onGroupNamesToggleClick(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.
*/
onAddGroupButtonClick: function onAddGroupButtonClick(event) {
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) {
endGroupDrag: function endGroupDrag(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) {
startButtonDrag: function startButtonDrag(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) {
endButtonDrag: function endButtonDrag(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.
applySorting: function applySorting() {
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',
@ -208,66 +103,43 @@
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 () {
insertPlaceholders: function insertPlaceholders() {
this.insertPlaceholderRow();
this.insertNewGroupButtons();
},
/**
* Inserts a blank row at the bottom of the CKEditor configuration.
*/
insertPlaceholderRow: function () {
insertPlaceholderRow: function insertPlaceholderRow() {
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'));
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();
}).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.
insertNewGroupButtons: function insertNewGroupButtons() {
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'));
}
} else if (!$groups.eq(-1).hasClass('ckeditor-add-new-group')) {
$button.appendTo($row.children('.ckeditor-toolbar-groups'));
}
});
}
});
})(Drupal, Backbone, jQuery);
})(Drupal, Backbone, jQuery);