3 * CKEditor implementation of {@link Drupal.editors} API.
6 (function(Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) {
10 Drupal.editors.ckeditor = {
12 * Editor attach callback.
14 * @param {HTMLElement} element
15 * The element to attach the editor to.
16 * @param {string} format
17 * The text format for the editor.
20 * Whether the call to `CKEDITOR.replace()` created an editor or not.
22 attach(element, format) {
23 this._loadExternalPlugins(format);
24 // Also pass settings that are Drupal-specific.
25 format.editorSettings.drupal = {
26 format: format.format,
29 // Set a title on the CKEditor instance that includes the text field's
30 // label so that screen readers say something that is understandable
32 const label = $(`label[for=${element.getAttribute('id')}]`).html();
33 format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', {
37 return !!CKEDITOR.replace(element, format.editorSettings);
41 * Editor detach callback.
43 * @param {HTMLElement} element
44 * The element to detach the editor from.
45 * @param {string} format
46 * The text format used for the editor.
47 * @param {string} trigger
48 * The event trigger for the detach.
51 * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
52 * found an editor or not.
54 detach(element, format, trigger) {
55 const editor = CKEDITOR.dom.element.get(element).getEditor();
57 if (trigger === 'serialize') {
58 editor.updateElement();
61 element.removeAttribute('contentEditable');
68 * Reacts on a change in the editor element.
70 * @param {HTMLElement} element
71 * The element where the change occurred.
72 * @param {function} callback
73 * Callback called with the value of the editor.
76 * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
77 * found an editor or not.
79 onChange(element, callback) {
80 const editor = CKEDITOR.dom.element.get(element).getEditor();
85 callback(editor.getData());
89 // A temporary workaround to control scrollbar appearance when using
90 // autoGrow event to control editor's height.
91 // @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed.
92 editor.on('mode', () => {
93 const editable = editor.editable();
94 if (!editable.isInline()) {
98 const doc = evt.editor.document;
99 const scrollable = CKEDITOR.env.quirks
101 : doc.getDocumentElement();
103 if (scrollable.$.scrollHeight < scrollable.$.clientHeight) {
104 scrollable.setStyle('overflow-y', 'hidden');
106 scrollable.removeStyle('overflow-y');
120 * Attaches an inline editor to a DOM element.
122 * @param {HTMLElement} element
123 * The element to attach the editor to.
124 * @param {object} format
125 * The text format used in the editor.
126 * @param {string} [mainToolbarId]
127 * The id attribute for the main editor toolbar, if any.
128 * @param {string} [floatedToolbarId]
129 * The id attribute for the floated editor toolbar, if any.
132 * Whether the call to `CKEDITOR.replace()` created an editor or not.
134 attachInlineEditor(element, format, mainToolbarId, floatedToolbarId) {
135 this._loadExternalPlugins(format);
136 // Also pass settings that are Drupal-specific.
137 format.editorSettings.drupal = {
138 format: format.format,
141 const settings = $.extend(true, {}, format.editorSettings);
143 // If a toolbar is already provided for "true WYSIWYG" (in-place editing),
144 // then use that toolbar instead: override the default settings to render
145 // CKEditor UI's top toolbar into mainToolbar, and don't render the bottom
146 // toolbar at all. (CKEditor doesn't need a floated toolbar.)
148 const settingsOverride = {
149 extraPlugins: 'sharedspace',
150 removePlugins: 'floatingspace,elementspath',
156 // Find the "Source" button, if any, and replace it with "Sourcedialog".
157 // (The 'sourcearea' plugin only works in CKEditor's iframe mode.)
158 let sourceButtonFound = false;
161 !sourceButtonFound && i < settings.toolbar.length;
164 if (settings.toolbar[i] !== '/') {
167 !sourceButtonFound && j < settings.toolbar[i].items.length;
170 if (settings.toolbar[i].items[j] === 'Source') {
171 sourceButtonFound = true;
172 // Swap sourcearea's "Source" button for sourcedialog's.
173 settings.toolbar[i].items[j] = 'Sourcedialog';
174 settingsOverride.extraPlugins += ',sourcedialog';
175 settingsOverride.removePlugins += ',sourcearea';
181 settings.extraPlugins += `,${settingsOverride.extraPlugins}`;
182 settings.removePlugins += `,${settingsOverride.removePlugins}`;
183 settings.sharedSpaces = settingsOverride.sharedSpaces;
186 // CKEditor requires an element to already have the contentEditable
187 // attribute set to "true", otherwise it won't attach an inline editor.
188 element.setAttribute('contentEditable', 'true');
190 return !!CKEDITOR.inline(element, settings);
194 * Loads the required external plugins for the editor.
196 * @param {object} format
197 * The text format used in the editor.
199 _loadExternalPlugins(format) {
200 const externalPlugins = format.editorSettings.drupalExternalPlugins;
201 // Register and load additional CKEditor plugins as necessary.
202 if (externalPlugins) {
203 Object.keys(externalPlugins || {}).forEach(pluginName => {
204 CKEDITOR.plugins.addExternal(
206 externalPlugins[pluginName],
210 delete format.editorSettings.drupalExternalPlugins;
217 * Variable storing the current dialog's save callback.
224 * Open a dialog for a Drupal-based plugin.
226 * This dynamically loads jQuery UI (if necessary) using the Drupal AJAX
227 * framework, then opens a dialog at the specified Drupal path.
229 * @param {CKEditor} editor
230 * The CKEditor instance that is opening the dialog.
231 * @param {string} url
232 * The URL that contains the contents of the dialog.
233 * @param {object} existingValues
234 * Existing values that will be sent via POST to the url for the dialog
236 * @param {function} saveCallback
237 * A function to be called upon saving the dialog.
238 * @param {object} dialogSettings
239 * An object containing settings to be passed to the jQuery UI.
241 openDialog(editor, url, existingValues, saveCallback, dialogSettings) {
242 // Locate a suitable place to display our loading indicator.
243 let $target = $(editor.container.$);
244 if (editor.elementMode === CKEDITOR.ELEMENT_MODE_REPLACE) {
245 $target = $target.find('.cke_contents');
248 // Remove any previous loading indicator.
250 .css('position', 'relative')
251 .find('.ckeditor-dialog-loading')
254 // Add a consistent dialog class.
255 const classes = dialogSettings.dialogClass
256 ? dialogSettings.dialogClass.split(' ')
258 classes.push('ui-dialog--narrow');
259 dialogSettings.dialogClass = classes.join(' ');
260 dialogSettings.autoResize = window.matchMedia(
261 '(min-width: 600px)',
263 dialogSettings.width = 'auto';
265 // Add a "Loading…" message, hide it underneath the CKEditor toolbar,
266 // create a Drupal.Ajax instance to load the dialog and trigger it.
268 `<div class="ckeditor-dialog-loading"><span style="top: -40px;" class="ckeditor-dialog-loading-link">${Drupal.t(
272 $content.appendTo($target);
274 const ckeditorAjaxDialog = Drupal.ajax({
275 dialog: dialogSettings,
277 selector: '.ckeditor-dialog-loading-link',
279 progress: { type: 'throbber' },
281 editor_object: existingValues,
284 ckeditorAjaxDialog.execute();
286 // After a short delay, show "Loading…" message.
287 window.setTimeout(() => {
288 $content.find('span').animate({ top: '0px' });
291 // Store the save callback to be executed when this dialog is closed.
292 Drupal.ckeditor.saveCallback = saveCallback;
296 // Moves the dialog to the top of the CKEDITOR stack.
297 $(window).on('dialogcreate', (e, dialog, $element, settings) => {
298 $('.ui-dialog--narrow').css('zIndex', CKEDITOR.config.baseFloatZIndex + 1);
301 // Respond to new dialogs that are opened by CKEditor, closing the AJAX loader.
302 $(window).on('dialog:beforecreate', (e, dialog, $element, settings) => {
303 $('.ckeditor-dialog-loading').animate({ top: '-40px' }, function() {
308 // Respond to dialogs that are saved, sending data back to CKEditor.
309 $(window).on('editor:dialogsave', (e, values) => {
310 if (Drupal.ckeditor.saveCallback) {
311 Drupal.ckeditor.saveCallback(values);
315 // Respond to dialogs that are closed, removing the current save handler.
316 $(window).on('dialog:afterclose', (e, dialog, $element) => {
317 if (Drupal.ckeditor.saveCallback) {
318 Drupal.ckeditor.saveCallback = null;
322 // Formulate a default formula for the maximum autoGrow height.
323 $(document).on('drupalViewportOffsetChange', () => {
324 CKEDITOR.config.autoGrow_maxHeight =
326 (window.innerHeight - displace.offsets.top - displace.offsets.bottom);
329 // Redirect on hash change when the original hash has an associated CKEditor.
330 function redirectTextareaFragmentToCKEditorInstance() {
331 const hash = window.location.hash.substr(1);
332 const element = document.getElementById(hash);
334 const editor = CKEDITOR.dom.element.get(element).getEditor();
336 const id = editor.container.getAttribute('id');
337 window.location.replace(`#${id}`);
342 'hashchange.ckeditor',
343 redirectTextareaFragmentToCKEditorInstance,
346 // Set autoGrow to make the editor grow the moment it is created.
347 CKEDITOR.config.autoGrow_onStartup = true;
349 // Set the CKEditor cache-busting string to the same value as Drupal.
350 CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp;
354 * Command to add style sheets to a CKEditor instance.
356 * Works for both iframe and inline CKEditor instances.
358 * @param {Drupal.Ajax} [ajax]
359 * {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
360 * @param {object} response
361 * The response from the Ajax request.
362 * @param {string} response.editor_id
363 * The CKEditor instance ID.
364 * @param {number} [status]
365 * The XMLHttpRequest status.
367 * @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document
369 AjaxCommands.prototype.ckeditor_add_stylesheet = function(
374 const editor = CKEDITOR.instances[response.editor_id];
377 response.stylesheets.forEach(url => {
378 editor.document.appendStyleSheet(url);