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