Chris@0: /** Chris@0: * @file Chris@0: * CKEditor implementation of {@link Drupal.editors} API. Chris@0: */ Chris@0: Chris@17: (function(Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) { Chris@0: /** Chris@0: * @namespace Chris@0: */ Chris@0: Drupal.editors.ckeditor = { Chris@0: /** Chris@0: * Editor attach callback. Chris@0: * Chris@0: * @param {HTMLElement} element Chris@0: * The element to attach the editor to. Chris@0: * @param {string} format Chris@0: * The text format for the editor. Chris@0: * Chris@0: * @return {bool} Chris@0: * Whether the call to `CKEDITOR.replace()` created an editor or not. Chris@0: */ Chris@0: attach(element, format) { Chris@0: this._loadExternalPlugins(format); Chris@0: // Also pass settings that are Drupal-specific. Chris@0: format.editorSettings.drupal = { Chris@0: format: format.format, Chris@0: }; Chris@0: Chris@0: // Set a title on the CKEditor instance that includes the text field's Chris@0: // label so that screen readers say something that is understandable Chris@0: // for end users. Chris@0: const label = $(`label[for=${element.getAttribute('id')}]`).html(); Chris@17: format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', { Chris@17: '!label': label, Chris@17: }); Chris@0: Chris@0: return !!CKEDITOR.replace(element, format.editorSettings); Chris@0: }, Chris@0: Chris@0: /** Chris@0: * Editor detach callback. Chris@0: * Chris@0: * @param {HTMLElement} element Chris@0: * The element to detach the editor from. Chris@0: * @param {string} format Chris@0: * The text format used for the editor. Chris@0: * @param {string} trigger Chris@0: * The event trigger for the detach. Chris@0: * Chris@0: * @return {bool} Chris@0: * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()` Chris@0: * found an editor or not. Chris@0: */ Chris@0: detach(element, format, trigger) { Chris@0: const editor = CKEDITOR.dom.element.get(element).getEditor(); Chris@0: if (editor) { Chris@0: if (trigger === 'serialize') { Chris@0: editor.updateElement(); Chris@17: } else { Chris@0: editor.destroy(); Chris@0: element.removeAttribute('contentEditable'); Chris@0: } Chris@0: } Chris@0: return !!editor; Chris@0: }, Chris@0: Chris@0: /** Chris@0: * Reacts on a change in the editor element. Chris@0: * Chris@0: * @param {HTMLElement} element Chris@17: * The element where the change occurred. Chris@0: * @param {function} callback Chris@0: * Callback called with the value of the editor. Chris@0: * Chris@0: * @return {bool} Chris@0: * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()` Chris@0: * found an editor or not. Chris@0: */ Chris@0: onChange(element, callback) { Chris@0: const editor = CKEDITOR.dom.element.get(element).getEditor(); Chris@0: if (editor) { Chris@17: editor.on( Chris@17: 'change', Chris@17: debounce(() => { Chris@17: callback(editor.getData()); Chris@17: }, 400), Chris@17: ); Chris@0: Chris@0: // A temporary workaround to control scrollbar appearance when using Chris@0: // autoGrow event to control editor's height. Chris@0: // @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed. Chris@0: editor.on('mode', () => { Chris@0: const editable = editor.editable(); Chris@0: if (!editable.isInline()) { Chris@17: editor.on( Chris@17: 'autoGrow', Chris@17: evt => { Chris@17: const doc = evt.editor.document; Chris@17: const scrollable = CKEDITOR.env.quirks Chris@17: ? doc.getBody() Chris@17: : doc.getDocumentElement(); Chris@0: Chris@17: if (scrollable.$.scrollHeight < scrollable.$.clientHeight) { Chris@17: scrollable.setStyle('overflow-y', 'hidden'); Chris@17: } else { Chris@17: scrollable.removeStyle('overflow-y'); Chris@17: } Chris@17: }, Chris@17: null, Chris@17: null, Chris@17: 10000, Chris@17: ); Chris@0: } Chris@0: }); Chris@0: } Chris@0: return !!editor; Chris@0: }, Chris@0: Chris@0: /** Chris@0: * Attaches an inline editor to a DOM element. Chris@0: * Chris@0: * @param {HTMLElement} element Chris@0: * The element to attach the editor to. Chris@0: * @param {object} format Chris@0: * The text format used in the editor. Chris@0: * @param {string} [mainToolbarId] Chris@0: * The id attribute for the main editor toolbar, if any. Chris@0: * @param {string} [floatedToolbarId] Chris@0: * The id attribute for the floated editor toolbar, if any. Chris@0: * Chris@0: * @return {bool} Chris@0: * Whether the call to `CKEDITOR.replace()` created an editor or not. Chris@0: */ Chris@0: attachInlineEditor(element, format, mainToolbarId, floatedToolbarId) { Chris@0: this._loadExternalPlugins(format); Chris@0: // Also pass settings that are Drupal-specific. Chris@0: format.editorSettings.drupal = { Chris@0: format: format.format, Chris@0: }; Chris@0: Chris@0: const settings = $.extend(true, {}, format.editorSettings); Chris@0: Chris@0: // If a toolbar is already provided for "true WYSIWYG" (in-place editing), Chris@0: // then use that toolbar instead: override the default settings to render Chris@0: // CKEditor UI's top toolbar into mainToolbar, and don't render the bottom Chris@0: // toolbar at all. (CKEditor doesn't need a floated toolbar.) Chris@0: if (mainToolbarId) { Chris@0: const settingsOverride = { Chris@0: extraPlugins: 'sharedspace', Chris@0: removePlugins: 'floatingspace,elementspath', Chris@0: sharedSpaces: { Chris@0: top: mainToolbarId, Chris@0: }, Chris@0: }; Chris@0: Chris@0: // Find the "Source" button, if any, and replace it with "Sourcedialog". Chris@0: // (The 'sourcearea' plugin only works in CKEditor's iframe mode.) Chris@0: let sourceButtonFound = false; Chris@17: for ( Chris@17: let i = 0; Chris@17: !sourceButtonFound && i < settings.toolbar.length; Chris@17: i++ Chris@17: ) { Chris@0: if (settings.toolbar[i] !== '/') { Chris@17: for ( Chris@17: let j = 0; Chris@17: !sourceButtonFound && j < settings.toolbar[i].items.length; Chris@17: j++ Chris@17: ) { Chris@0: if (settings.toolbar[i].items[j] === 'Source') { Chris@0: sourceButtonFound = true; Chris@0: // Swap sourcearea's "Source" button for sourcedialog's. Chris@0: settings.toolbar[i].items[j] = 'Sourcedialog'; Chris@0: settingsOverride.extraPlugins += ',sourcedialog'; Chris@0: settingsOverride.removePlugins += ',sourcearea'; Chris@0: } Chris@0: } Chris@0: } Chris@0: } Chris@0: Chris@0: settings.extraPlugins += `,${settingsOverride.extraPlugins}`; Chris@0: settings.removePlugins += `,${settingsOverride.removePlugins}`; Chris@0: settings.sharedSpaces = settingsOverride.sharedSpaces; Chris@0: } Chris@0: Chris@0: // CKEditor requires an element to already have the contentEditable Chris@0: // attribute set to "true", otherwise it won't attach an inline editor. Chris@0: element.setAttribute('contentEditable', 'true'); Chris@0: Chris@0: return !!CKEDITOR.inline(element, settings); Chris@0: }, Chris@0: Chris@0: /** Chris@0: * Loads the required external plugins for the editor. Chris@0: * Chris@0: * @param {object} format Chris@0: * The text format used in the editor. Chris@0: */ Chris@0: _loadExternalPlugins(format) { Chris@0: const externalPlugins = format.editorSettings.drupalExternalPlugins; Chris@0: // Register and load additional CKEditor plugins as necessary. Chris@0: if (externalPlugins) { Chris@17: Object.keys(externalPlugins || {}).forEach(pluginName => { Chris@17: CKEDITOR.plugins.addExternal( Chris@17: pluginName, Chris@17: externalPlugins[pluginName], Chris@17: '', Chris@17: ); Chris@14: }); Chris@0: delete format.editorSettings.drupalExternalPlugins; Chris@0: } Chris@0: }, Chris@0: }; Chris@0: Chris@0: Drupal.ckeditor = { Chris@0: /** Chris@0: * Variable storing the current dialog's save callback. Chris@0: * Chris@0: * @type {?function} Chris@0: */ Chris@0: saveCallback: null, Chris@0: Chris@0: /** Chris@0: * Open a dialog for a Drupal-based plugin. Chris@0: * Chris@0: * This dynamically loads jQuery UI (if necessary) using the Drupal AJAX Chris@0: * framework, then opens a dialog at the specified Drupal path. Chris@0: * Chris@0: * @param {CKEditor} editor Chris@0: * The CKEditor instance that is opening the dialog. Chris@0: * @param {string} url Chris@0: * The URL that contains the contents of the dialog. Chris@0: * @param {object} existingValues Chris@0: * Existing values that will be sent via POST to the url for the dialog Chris@0: * contents. Chris@0: * @param {function} saveCallback Chris@0: * A function to be called upon saving the dialog. Chris@0: * @param {object} dialogSettings Chris@0: * An object containing settings to be passed to the jQuery UI. Chris@0: */ Chris@0: openDialog(editor, url, existingValues, saveCallback, dialogSettings) { Chris@0: // Locate a suitable place to display our loading indicator. Chris@0: let $target = $(editor.container.$); Chris@0: if (editor.elementMode === CKEDITOR.ELEMENT_MODE_REPLACE) { Chris@0: $target = $target.find('.cke_contents'); Chris@0: } Chris@0: Chris@0: // Remove any previous loading indicator. Chris@17: $target Chris@17: .css('position', 'relative') Chris@17: .find('.ckeditor-dialog-loading') Chris@17: .remove(); Chris@0: Chris@0: // Add a consistent dialog class. Chris@17: const classes = dialogSettings.dialogClass Chris@17: ? dialogSettings.dialogClass.split(' ') Chris@17: : []; Chris@0: classes.push('ui-dialog--narrow'); Chris@0: dialogSettings.dialogClass = classes.join(' '); Chris@17: dialogSettings.autoResize = window.matchMedia( Chris@17: '(min-width: 600px)', Chris@17: ).matches; Chris@0: dialogSettings.width = 'auto'; Chris@0: Chris@0: // Add a "Loading…" message, hide it underneath the CKEditor toolbar, Chris@0: // create a Drupal.Ajax instance to load the dialog and trigger it. Chris@17: const $content = $( Chris@17: `
${Drupal.t( Chris@17: 'Loading...', Chris@17: )}
`, Chris@17: ); Chris@0: $content.appendTo($target); Chris@0: Chris@0: const ckeditorAjaxDialog = Drupal.ajax({ Chris@0: dialog: dialogSettings, Chris@0: dialogType: 'modal', Chris@0: selector: '.ckeditor-dialog-loading-link', Chris@0: url, Chris@0: progress: { type: 'throbber' }, Chris@0: submit: { Chris@0: editor_object: existingValues, Chris@0: }, Chris@0: }); Chris@0: ckeditorAjaxDialog.execute(); Chris@0: Chris@0: // After a short delay, show "Loading…" message. Chris@0: window.setTimeout(() => { Chris@0: $content.find('span').animate({ top: '0px' }); Chris@0: }, 1000); Chris@0: Chris@0: // Store the save callback to be executed when this dialog is closed. Chris@0: Drupal.ckeditor.saveCallback = saveCallback; Chris@0: }, Chris@0: }; Chris@0: Chris@0: // Moves the dialog to the top of the CKEDITOR stack. Chris@0: $(window).on('dialogcreate', (e, dialog, $element, settings) => { Chris@0: $('.ui-dialog--narrow').css('zIndex', CKEDITOR.config.baseFloatZIndex + 1); Chris@0: }); Chris@0: Chris@0: // Respond to new dialogs that are opened by CKEditor, closing the AJAX loader. Chris@0: $(window).on('dialog:beforecreate', (e, dialog, $element, settings) => { Chris@17: $('.ckeditor-dialog-loading').animate({ top: '-40px' }, function() { Chris@0: $(this).remove(); Chris@0: }); Chris@0: }); Chris@0: Chris@0: // Respond to dialogs that are saved, sending data back to CKEditor. Chris@0: $(window).on('editor:dialogsave', (e, values) => { Chris@0: if (Drupal.ckeditor.saveCallback) { Chris@0: Drupal.ckeditor.saveCallback(values); Chris@0: } Chris@0: }); Chris@0: Chris@0: // Respond to dialogs that are closed, removing the current save handler. Chris@0: $(window).on('dialog:afterclose', (e, dialog, $element) => { Chris@0: if (Drupal.ckeditor.saveCallback) { Chris@0: Drupal.ckeditor.saveCallback = null; Chris@0: } Chris@0: }); Chris@0: Chris@0: // Formulate a default formula for the maximum autoGrow height. Chris@0: $(document).on('drupalViewportOffsetChange', () => { Chris@17: CKEDITOR.config.autoGrow_maxHeight = Chris@17: 0.7 * Chris@17: (window.innerHeight - displace.offsets.top - displace.offsets.bottom); Chris@0: }); Chris@0: Chris@0: // Redirect on hash change when the original hash has an associated CKEditor. Chris@0: function redirectTextareaFragmentToCKEditorInstance() { Chris@17: const hash = window.location.hash.substr(1); Chris@0: const element = document.getElementById(hash); Chris@0: if (element) { Chris@0: const editor = CKEDITOR.dom.element.get(element).getEditor(); Chris@0: if (editor) { Chris@0: const id = editor.container.getAttribute('id'); Chris@17: window.location.replace(`#${id}`); Chris@0: } Chris@0: } Chris@0: } Chris@17: $(window).on( Chris@17: 'hashchange.ckeditor', Chris@17: redirectTextareaFragmentToCKEditorInstance, Chris@17: ); Chris@0: Chris@0: // Set autoGrow to make the editor grow the moment it is created. Chris@0: CKEDITOR.config.autoGrow_onStartup = true; Chris@0: Chris@0: // Set the CKEditor cache-busting string to the same value as Drupal. Chris@0: CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp; Chris@0: Chris@0: if (AjaxCommands) { Chris@0: /** Chris@0: * Command to add style sheets to a CKEditor instance. Chris@0: * Chris@0: * Works for both iframe and inline CKEditor instances. Chris@0: * Chris@0: * @param {Drupal.Ajax} [ajax] Chris@0: * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. Chris@0: * @param {object} response Chris@0: * The response from the Ajax request. Chris@0: * @param {string} response.editor_id Chris@0: * The CKEditor instance ID. Chris@0: * @param {number} [status] Chris@0: * The XMLHttpRequest status. Chris@0: * Chris@0: * @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document Chris@0: */ Chris@17: AjaxCommands.prototype.ckeditor_add_stylesheet = function( Chris@17: ajax, Chris@17: response, Chris@17: status, Chris@17: ) { Chris@0: const editor = CKEDITOR.instances[response.editor_id]; Chris@0: Chris@0: if (editor) { Chris@17: response.stylesheets.forEach(url => { Chris@0: editor.document.appendStyleSheet(url); Chris@0: }); Chris@0: } Chris@0: }; Chris@0: } Chris@17: })( Chris@17: Drupal, Chris@17: Drupal.debounce, Chris@17: CKEDITOR, Chris@17: jQuery, Chris@17: Drupal.displace, Chris@17: Drupal.AjaxCommands, Chris@17: );