/**
* @file
* Attaches behavior for the Editor module.
*/
(function ($, Drupal, drupalSettings) {
/**
* Finds the text area field associated with the given text format selector.
*
* @param {jQuery} $formatSelector
* A text format selector DOM element.
*
* @return {HTMLElement}
* The text area DOM element, if it was found.
*/
function findFieldForFormatSelector($formatSelector) {
const fieldId = $formatSelector.attr('data-editor-for');
// This selector will only find text areas in the top-level document. We do
// not support attaching editors on text areas within iframes.
return $(`#${fieldId}`).get(0);
}
/**
* Filter away XSS attack vectors when switching text formats.
*
* @param {HTMLElement} field
* The textarea DOM element.
* @param {object} format
* The text format that's being activated, from
* drupalSettings.editor.formats.
* @param {string} originalFormatID
* The text format ID of the original text format.
* @param {function} callback
* A callback to be called (with no parameters) after the field's value has
* been XSS filtered.
*/
function filterXssWhenSwitching(field, format, originalFormatID, callback) {
// A text editor that already is XSS-safe needs no additional measures.
if (format.editor.isXssSafe) {
callback(field, format);
}
// Otherwise, ensure XSS safety: let the server XSS filter this value.
else {
$.ajax({
url: Drupal.url(`editor/filter_xss/${format.format}`),
type: 'POST',
data: {
value: field.value,
original_format_id: originalFormatID,
},
dataType: 'json',
success(xssFilteredValue) {
// If the server returns false, then no XSS filtering is needed.
if (xssFilteredValue !== false) {
field.value = xssFilteredValue;
}
callback(field, format);
},
});
}
}
/**
* Changes the text editor on a text area.
*
* @param {HTMLElement} field
* The text area DOM element.
* @param {string} newFormatID
* The text format we're changing to; the text editor for the currently
* active text format will be detached, and the text editor for the new text
* format will be attached.
*/
function changeTextEditor(field, newFormatID) {
const previousFormatID = field.getAttribute(
'data-editor-active-text-format',
);
// Detach the current editor (if any) and attach a new editor.
if (drupalSettings.editor.formats[previousFormatID]) {
Drupal.editorDetach(
field,
drupalSettings.editor.formats[previousFormatID],
);
}
// When no text editor is currently active, stop tracking changes.
else {
$(field).off('.editor');
}
// Attach the new text editor (if any).
if (drupalSettings.editor.formats[newFormatID]) {
const format = drupalSettings.editor.formats[newFormatID];
filterXssWhenSwitching(
field,
format,
previousFormatID,
Drupal.editorAttach,
);
}
// Store the new active format.
field.setAttribute('data-editor-active-text-format', newFormatID);
}
/**
* Handles changes in text format.
*
* @param {jQuery.Event} event
* The text format change event.
*/
function onTextFormatChange(event) {
const $select = $(event.target);
const field = event.data.field;
const activeFormatID = field.getAttribute('data-editor-active-text-format');
const newFormatID = $select.val();
// Prevent double-attaching if the change event is triggered manually.
if (newFormatID === activeFormatID) {
return;
}
// When changing to a text format that has a text editor associated
// with it that supports content filtering, then first ask for
// confirmation, because switching text formats might cause certain
// markup to be stripped away.
const supportContentFiltering =
drupalSettings.editor.formats[newFormatID] &&
drupalSettings.editor.formats[newFormatID].editorSupportsContentFiltering;
// If there is no content yet, it's always safe to change the text format.
const hasContent = field.value !== '';
if (hasContent && supportContentFiltering) {
const message = Drupal.t(
'Changing the text format to %text_format will permanently remove content that is not allowed in that text format.<br><br>Save your changes before switching the text format to avoid losing data.',
{
'%text_format': $select.find('option:selected').text(),
},
);
const confirmationDialog = Drupal.dialog(`<div>${message}</div>`, {
title: Drupal.t('Change text format?'),
dialogClass: 'editor-change-text-format-modal',
resizable: false,
buttons: [
{
text: Drupal.t('Continue'),
class: 'button button--primary',
click() {
changeTextEditor(field, newFormatID);
confirmationDialog.close();
},
},
{
text: Drupal.t('Cancel'),
class: 'button',
click() {
// Restore the active format ID: cancel changing text format. We
// cannot simply call event.preventDefault() because jQuery's
// change event is only triggered after the change has already
// been accepted.
$select.val(activeFormatID);
confirmationDialog.close();
},
},
],
// Prevent this modal from being closed without the user making a choice
// as per http://stackoverflow.com/a/5438771.
closeOnEscape: false,
create() {
$(this).parent().find('.ui-dialog-titlebar-close').remove();
},
beforeClose: false,
close(event) {
// Automatically destroy the DOM element that was used for the dialog.
$(event.target).remove();
},
});
confirmationDialog.showModal();
} else {
changeTextEditor(field, newFormatID);
}
}
/**
* Initialize an empty object for editors to place their attachment code.
*
* @namespace
*/
Drupal.editors = {};
/**
* Enables editors on text_format elements.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches an editor to an input element.
* @prop {Drupal~behaviorDetach} detach
* Detaches an editor from an input element.
*/
Drupal.behaviors.editor = {
attach(context, settings) {
// If there are no editor settings, there are no editors to enable.
if (!settings.editor) {
return;
}
$(context)
.find('[data-editor-for]')
.once('editor')
.each(function () {
const $this = $(this);
const field = findFieldForFormatSelector($this);
// Opt-out if no supported text area was found.
if (!field) {
return;
}
// Store the current active format.
const activeFormatID = $this.val();
field.setAttribute('data-editor-active-text-format', activeFormatID);
// Directly attach this text editor, if the text format is enabled.
if (settings.editor.formats[activeFormatID]) {
// XSS protection for the current text format/editor is performed on
// the server side, so we don't need to do anything special here.
Drupal.editorAttach(field, settings.editor.formats[activeFormatID]);
}
// When there is no text editor for this text format, still track
// changes, because the user has the ability to switch to some text
// editor, otherwise this code would not be executed.
$(field).on('change.editor keypress.editor', () => {
field.setAttribute('data-editor-value-is-changed', 'true');
// Just knowing that the value was changed is enough, stop tracking.
$(field).off('.editor');
});
// Attach onChange handler to text format selector element.
if ($this.is('select')) {
$this.on('change.editorAttach', { field }, onTextFormatChange);
}
// Detach any editor when the containing form is submitted.
$this.parents('form').on('submit', (event) => {
// Do not detach if the event was canceled.
if (event.isDefaultPrevented()) {
return;
}
// Detach the current editor (if any).
if (settings.editor.formats[activeFormatID]) {
Drupal.editorDetach(
field,
settings.editor.formats[activeFormatID],
'serialize',
);
}
});
});
},
detach(context, settings, trigger) {
let editors;
// The 'serialize' trigger indicates that we should simply update the
// underlying element with the new text, without destroying the editor.
if (trigger === 'serialize') {
// Removing the editor-processed class guarantees that the editor will
// be reattached. Only do this if we're planning to destroy the editor.
editors = $(context).find('[data-editor-for]').findOnce('editor');
} else {
editors = $(context).find('[data-editor-for]').removeOnce('editor');
}
editors.each(function () {
const $this = $(this);
const activeFormatID = $this.val();
const field = findFieldForFormatSelector($this);
if (field && activeFormatID in settings.editor.formats) {
Drupal.editorDetach(
field,
settings.editor.formats[activeFormatID],
trigger,
);
}
});
},
};
/**
* Attaches editor behaviors to the field.
*
* @param {HTMLElement} field
* The textarea DOM element.
* @param {object} format
* The text format that's being activated, from
* drupalSettings.editor.formats.
*
* @listens event:change
*
* @fires event:formUpdated
*/
Drupal.editorAttach = function (field, format) {
if (format.editor) {
// Attach the text editor.
Drupal.editors[format.editor].attach(field, format);
// Ensures form.js' 'formUpdated' event is triggered even for changes that
// happen within the text editor.
Drupal.editors[format.editor].onChange(field, () => {
$(field).trigger('formUpdated');
// Keep track of changes, so we know what to do when switching text
// formats and guaranteeing XSS protection.
field.setAttribute('data-editor-value-is-changed', 'true');
});
}
};
/**
* Detaches editor behaviors from the field.
*
* @param {HTMLElement} field
* The textarea DOM element.
* @param {object} format
* The text format that's being activated, from
* drupalSettings.editor.formats.
* @param {string} trigger
* Trigger value from the detach behavior.
*/
Drupal.editorDetach = function (field, format, trigger) {
if (format.editor) {
Drupal.editors[format.editor].detach(field, format, trigger);
// Restore the original value if the user didn't make any changes yet.
if (field.getAttribute('data-editor-value-is-changed') === 'false') {
field.value = field.getAttribute('data-editor-value-original');
}
}
};
})(jQuery, Drupal, drupalSettings);