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);