Source: modules/ckeditor/js/ckeditor.es6.js

/**
 * @file
 * CKEditor implementation of {@link Drupal.editors} API.
 */

(function (Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) {
  /**
   * @namespace
   */
  Drupal.editors.ckeditor = {
    /**
     * Editor attach callback.
     *
     * @param {HTMLElement} element
     *   The element to attach the editor to.
     * @param {string} format
     *   The text format for the editor.
     *
     * @return {bool}
     *   Whether the call to `CKEDITOR.replace()` created an editor or not.
     */
    attach(element, format) {
      this._loadExternalPlugins(format);
      // Also pass settings that are Drupal-specific.
      format.editorSettings.drupal = {
        format: format.format,
      };

      // Set a title on the CKEditor instance that includes the text field's
      // label so that screen readers say something that is understandable
      // for end users.
      const label = $(`label[for=${element.getAttribute('id')}]`).html();
      format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', {
        '!label': label,
      });

      return !!CKEDITOR.replace(element, format.editorSettings);
    },

    /**
     * Editor detach callback.
     *
     * @param {HTMLElement} element
     *   The element to detach the editor from.
     * @param {string} format
     *   The text format used for the editor.
     * @param {string} trigger
     *   The event trigger for the detach.
     *
     * @return {bool}
     *   Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
     *   found an editor or not.
     */
    detach(element, format, trigger) {
      const editor = CKEDITOR.dom.element.get(element).getEditor();
      if (editor) {
        if (trigger === 'serialize') {
          editor.updateElement();
        } else {
          editor.destroy();
          element.removeAttribute('contentEditable');
        }
      }
      return !!editor;
    },

    /**
     * Reacts on a change in the editor element.
     *
     * @param {HTMLElement} element
     *   The element where the change occurred.
     * @param {function} callback
     *   Callback called with the value of the editor.
     *
     * @return {bool}
     *   Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
     *   found an editor or not.
     */
    onChange(element, callback) {
      const editor = CKEDITOR.dom.element.get(element).getEditor();
      if (editor) {
        editor.on(
          'change',
          debounce(() => {
            callback(editor.getData());
          }, 400),
        );

        // A temporary workaround to control scrollbar appearance when using
        // autoGrow event to control editor's height.
        // @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed.
        editor.on('mode', () => {
          const editable = editor.editable();
          if (!editable.isInline()) {
            editor.on(
              'autoGrow',
              (evt) => {
                const doc = evt.editor.document;
                const scrollable = CKEDITOR.env.quirks
                  ? doc.getBody()
                  : doc.getDocumentElement();

                if (scrollable.$.scrollHeight < scrollable.$.clientHeight) {
                  scrollable.setStyle('overflow-y', 'hidden');
                } else {
                  scrollable.removeStyle('overflow-y');
                }
              },
              null,
              null,
              10000,
            );
          }
        });
      }
      return !!editor;
    },

    /**
     * Attaches an inline editor to a DOM element.
     *
     * @param {HTMLElement} element
     *   The element to attach the editor to.
     * @param {object} format
     *   The text format used in the editor.
     * @param {string} [mainToolbarId]
     *   The id attribute for the main editor toolbar, if any.
     * @param {string} [floatedToolbarId]
     *   The id attribute for the floated editor toolbar, if any.
     *
     * @return {bool}
     *   Whether the call to `CKEDITOR.replace()` created an editor or not.
     */
    attachInlineEditor(element, format, mainToolbarId, floatedToolbarId) {
      this._loadExternalPlugins(format);
      // Also pass settings that are Drupal-specific.
      format.editorSettings.drupal = {
        format: format.format,
      };

      const settings = $.extend(true, {}, format.editorSettings);

      // If a toolbar is already provided for "true WYSIWYG" (in-place editing),
      // then use that toolbar instead: override the default settings to render
      // CKEditor UI's top toolbar into mainToolbar, and don't render the bottom
      // toolbar at all. (CKEditor doesn't need a floated toolbar.)
      if (mainToolbarId) {
        const settingsOverride = {
          extraPlugins: 'sharedspace',
          removePlugins: 'floatingspace,elementspath',
          sharedSpaces: {
            top: mainToolbarId,
          },
        };

        // Find the "Source" button, if any, and replace it with "Sourcedialog".
        // (The 'sourcearea' plugin only works in CKEditor's iframe mode.)
        let sourceButtonFound = false;
        for (
          let i = 0;
          !sourceButtonFound && i < settings.toolbar.length;
          i++
        ) {
          if (settings.toolbar[i] !== '/') {
            for (
              let j = 0;
              !sourceButtonFound && j < settings.toolbar[i].items.length;
              j++
            ) {
              if (settings.toolbar[i].items[j] === 'Source') {
                sourceButtonFound = true;
                // Swap sourcearea's "Source" button for sourcedialog's.
                settings.toolbar[i].items[j] = 'Sourcedialog';
                settingsOverride.extraPlugins += ',sourcedialog';
                settingsOverride.removePlugins += ',sourcearea';
              }
            }
          }
        }

        settings.extraPlugins += `,${settingsOverride.extraPlugins}`;
        settings.removePlugins += `,${settingsOverride.removePlugins}`;
        settings.sharedSpaces = settingsOverride.sharedSpaces;
      }

      // CKEditor requires an element to already have the contentEditable
      // attribute set to "true", otherwise it won't attach an inline editor.
      element.setAttribute('contentEditable', 'true');

      return !!CKEDITOR.inline(element, settings);
    },

    /**
     * Loads the required external plugins for the editor.
     *
     * @param {object} format
     *   The text format used in the editor.
     */
    _loadExternalPlugins(format) {
      const externalPlugins = format.editorSettings.drupalExternalPlugins;
      // Register and load additional CKEditor plugins as necessary.
      if (externalPlugins) {
        Object.keys(externalPlugins || {}).forEach((pluginName) => {
          CKEDITOR.plugins.addExternal(
            pluginName,
            externalPlugins[pluginName],
            '',
          );
        });
        delete format.editorSettings.drupalExternalPlugins;
      }
    },
  };

  Drupal.ckeditor = {
    /**
     * Variable storing the current dialog's save callback.
     *
     * @type {?function}
     */
    saveCallback: null,

    /**
     * Open a dialog for a Drupal-based plugin.
     *
     * This dynamically loads jQuery UI (if necessary) using the Drupal AJAX
     * framework, then opens a dialog at the specified Drupal path.
     *
     * @param {CKEditor} editor
     *   The CKEditor instance that is opening the dialog.
     * @param {string} url
     *   The URL that contains the contents of the dialog.
     * @param {object} existingValues
     *   Existing values that will be sent via POST to the url for the dialog
     *   contents.
     * @param {function} saveCallback
     *   A function to be called upon saving the dialog.
     * @param {object} dialogSettings
     *   An object containing settings to be passed to the jQuery UI.
     */
    openDialog(editor, url, existingValues, saveCallback, dialogSettings) {
      // Locate a suitable place to display our loading indicator.
      let $target = $(editor.container.$);
      if (editor.elementMode === CKEDITOR.ELEMENT_MODE_REPLACE) {
        $target = $target.find('.cke_contents');
      }

      // Remove any previous loading indicator.
      $target
        .css('position', 'relative')
        .find('.ckeditor-dialog-loading')
        .remove();

      // Add a consistent dialog class.
      const classes = dialogSettings.dialogClass
        ? dialogSettings.dialogClass.split(' ')
        : [];
      classes.push('ui-dialog--narrow');
      dialogSettings.dialogClass = classes.join(' ');
      dialogSettings.autoResize = window.matchMedia(
        '(min-width: 600px)',
      ).matches;
      dialogSettings.width = 'auto';

      // Add a "Loading…" message, hide it underneath the CKEditor toolbar,
      // create a Drupal.Ajax instance to load the dialog and trigger it.
      const $content = $(
        `<div class="ckeditor-dialog-loading"><span style="top: -40px;" class="ckeditor-dialog-loading-link">${Drupal.t(
          'Loading...',
        )}</span></div>`,
      );
      $content.appendTo($target);

      const ckeditorAjaxDialog = Drupal.ajax({
        dialog: dialogSettings,
        dialogType: 'modal',
        selector: '.ckeditor-dialog-loading-link',
        url,
        progress: { type: 'throbber' },
        submit: {
          editor_object: existingValues,
        },
      });
      ckeditorAjaxDialog.execute();

      // After a short delay, show "Loading…" message.
      window.setTimeout(() => {
        $content.find('span').animate({ top: '0px' });
      }, 1000);

      // Store the save callback to be executed when this dialog is closed.
      Drupal.ckeditor.saveCallback = saveCallback;
    },
  };

  // Moves the dialog to the top of the CKEDITOR stack.
  $(window).on('dialogcreate', (e, dialog, $element, settings) => {
    $('.ui-dialog--narrow').css('zIndex', CKEDITOR.config.baseFloatZIndex + 1);
  });

  // Respond to new dialogs that are opened by CKEditor, closing the AJAX loader.
  $(window).on('dialog:beforecreate', (e, dialog, $element, settings) => {
    $('.ckeditor-dialog-loading').animate({ top: '-40px' }, function () {
      $(this).remove();
    });
  });

  // Respond to dialogs that are saved, sending data back to CKEditor.
  $(window).on('editor:dialogsave', (e, values) => {
    if (Drupal.ckeditor.saveCallback) {
      Drupal.ckeditor.saveCallback(values);
    }
  });

  // Respond to dialogs that are closed, removing the current save handler.
  $(window).on('dialog:afterclose', (e, dialog, $element) => {
    if (Drupal.ckeditor.saveCallback) {
      Drupal.ckeditor.saveCallback = null;
    }
  });

  // Formulate a default formula for the maximum autoGrow height.
  $(document).on('drupalViewportOffsetChange', () => {
    CKEDITOR.config.autoGrow_maxHeight =
      0.7 *
      (window.innerHeight - displace.offsets.top - displace.offsets.bottom);
  });

  // Redirect on hash change when the original hash has an associated CKEditor.
  function redirectTextareaFragmentToCKEditorInstance() {
    const hash = window.location.hash.substr(1);
    const element = document.getElementById(hash);
    if (element) {
      const editor = CKEDITOR.dom.element.get(element).getEditor();
      if (editor) {
        const id = editor.container.getAttribute('id');
        window.location.replace(`#${id}`);
      }
    }
  }
  $(window).on(
    'hashchange.ckeditor',
    redirectTextareaFragmentToCKEditorInstance,
  );

  // Set autoGrow to make the editor grow the moment it is created.
  CKEDITOR.config.autoGrow_onStartup = true;

  // Default max height. Will be updated as the viewport changes.
  CKEDITOR.config.autoGrow_maxHeight = 0.7 * window.innerHeight;

  // Set the CKEditor cache-busting string to the same value as Drupal.
  CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp;

  if (AjaxCommands) {
    /**
     * Command to add style sheets to a CKEditor instance.
     *
     * Works for both iframe and inline CKEditor instances.
     *
     * @param {Drupal.Ajax} [ajax]
     *   {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
     * @param {object} response
     *   The response from the Ajax request.
     * @param {string} response.editor_id
     *   The CKEditor instance ID.
     * @param {number} [status]
     *   The XMLHttpRequest status.
     *
     * @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document
     */
    AjaxCommands.prototype.ckeditor_add_stylesheet = function (
      ajax,
      response,
      status,
    ) {
      const editor = CKEDITOR.instances[response.editor_id];

      if (editor) {
        response.stylesheets.forEach((url) => {
          editor.document.appendStyleSheet(url);
        });
      }
    };
  }
})(
  Drupal,
  Drupal.debounce,
  CKEDITOR,
  jQuery,
  Drupal.displace,
  Drupal.AjaxCommands,
);