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@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, _);