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