ckeditor.es6.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. /**
  2. * @file
  3. * CKEditor implementation of {@link Drupal.editors} API.
  4. */
  5. (function(Drupal, debounce, CKEDITOR, $, displace, AjaxCommands) {
  6. /**
  7. * @namespace
  8. */
  9. Drupal.editors.ckeditor = {
  10. /**
  11. * Editor attach callback.
  12. *
  13. * @param {HTMLElement} element
  14. * The element to attach the editor to.
  15. * @param {string} format
  16. * The text format for the editor.
  17. *
  18. * @return {bool}
  19. * Whether the call to `CKEDITOR.replace()` created an editor or not.
  20. */
  21. attach(element, format) {
  22. this._loadExternalPlugins(format);
  23. // Also pass settings that are Drupal-specific.
  24. format.editorSettings.drupal = {
  25. format: format.format,
  26. };
  27. // Set a title on the CKEditor instance that includes the text field's
  28. // label so that screen readers say something that is understandable
  29. // for end users.
  30. const label = $(`label[for=${element.getAttribute('id')}]`).html();
  31. format.editorSettings.title = Drupal.t('Rich Text Editor, !label field', {
  32. '!label': label,
  33. });
  34. return !!CKEDITOR.replace(element, format.editorSettings);
  35. },
  36. /**
  37. * Editor detach callback.
  38. *
  39. * @param {HTMLElement} element
  40. * The element to detach the editor from.
  41. * @param {string} format
  42. * The text format used for the editor.
  43. * @param {string} trigger
  44. * The event trigger for the detach.
  45. *
  46. * @return {bool}
  47. * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
  48. * found an editor or not.
  49. */
  50. detach(element, format, trigger) {
  51. const editor = CKEDITOR.dom.element.get(element).getEditor();
  52. if (editor) {
  53. if (trigger === 'serialize') {
  54. editor.updateElement();
  55. } else {
  56. editor.destroy();
  57. element.removeAttribute('contentEditable');
  58. }
  59. }
  60. return !!editor;
  61. },
  62. /**
  63. * Reacts on a change in the editor element.
  64. *
  65. * @param {HTMLElement} element
  66. * The element where the change occurred.
  67. * @param {function} callback
  68. * Callback called with the value of the editor.
  69. *
  70. * @return {bool}
  71. * Whether the call to `CKEDITOR.dom.element.get(element).getEditor()`
  72. * found an editor or not.
  73. */
  74. onChange(element, callback) {
  75. const editor = CKEDITOR.dom.element.get(element).getEditor();
  76. if (editor) {
  77. editor.on(
  78. 'change',
  79. debounce(() => {
  80. callback(editor.getData());
  81. }, 400),
  82. );
  83. // A temporary workaround to control scrollbar appearance when using
  84. // autoGrow event to control editor's height.
  85. // @todo Remove when http://dev.ckeditor.com/ticket/12120 is fixed.
  86. editor.on('mode', () => {
  87. const editable = editor.editable();
  88. if (!editable.isInline()) {
  89. editor.on(
  90. 'autoGrow',
  91. evt => {
  92. const doc = evt.editor.document;
  93. const scrollable = CKEDITOR.env.quirks
  94. ? doc.getBody()
  95. : doc.getDocumentElement();
  96. if (scrollable.$.scrollHeight < scrollable.$.clientHeight) {
  97. scrollable.setStyle('overflow-y', 'hidden');
  98. } else {
  99. scrollable.removeStyle('overflow-y');
  100. }
  101. },
  102. null,
  103. null,
  104. 10000,
  105. );
  106. }
  107. });
  108. }
  109. return !!editor;
  110. },
  111. /**
  112. * Attaches an inline editor to a DOM element.
  113. *
  114. * @param {HTMLElement} element
  115. * The element to attach the editor to.
  116. * @param {object} format
  117. * The text format used in the editor.
  118. * @param {string} [mainToolbarId]
  119. * The id attribute for the main editor toolbar, if any.
  120. * @param {string} [floatedToolbarId]
  121. * The id attribute for the floated editor toolbar, if any.
  122. *
  123. * @return {bool}
  124. * Whether the call to `CKEDITOR.replace()` created an editor or not.
  125. */
  126. attachInlineEditor(element, format, mainToolbarId, floatedToolbarId) {
  127. this._loadExternalPlugins(format);
  128. // Also pass settings that are Drupal-specific.
  129. format.editorSettings.drupal = {
  130. format: format.format,
  131. };
  132. const settings = $.extend(true, {}, format.editorSettings);
  133. // If a toolbar is already provided for "true WYSIWYG" (in-place editing),
  134. // then use that toolbar instead: override the default settings to render
  135. // CKEditor UI's top toolbar into mainToolbar, and don't render the bottom
  136. // toolbar at all. (CKEditor doesn't need a floated toolbar.)
  137. if (mainToolbarId) {
  138. const settingsOverride = {
  139. extraPlugins: 'sharedspace',
  140. removePlugins: 'floatingspace,elementspath',
  141. sharedSpaces: {
  142. top: mainToolbarId,
  143. },
  144. };
  145. // Find the "Source" button, if any, and replace it with "Sourcedialog".
  146. // (The 'sourcearea' plugin only works in CKEditor's iframe mode.)
  147. let sourceButtonFound = false;
  148. for (
  149. let i = 0;
  150. !sourceButtonFound && i < settings.toolbar.length;
  151. i++
  152. ) {
  153. if (settings.toolbar[i] !== '/') {
  154. for (
  155. let j = 0;
  156. !sourceButtonFound && j < settings.toolbar[i].items.length;
  157. j++
  158. ) {
  159. if (settings.toolbar[i].items[j] === 'Source') {
  160. sourceButtonFound = true;
  161. // Swap sourcearea's "Source" button for sourcedialog's.
  162. settings.toolbar[i].items[j] = 'Sourcedialog';
  163. settingsOverride.extraPlugins += ',sourcedialog';
  164. settingsOverride.removePlugins += ',sourcearea';
  165. }
  166. }
  167. }
  168. }
  169. settings.extraPlugins += `,${settingsOverride.extraPlugins}`;
  170. settings.removePlugins += `,${settingsOverride.removePlugins}`;
  171. settings.sharedSpaces = settingsOverride.sharedSpaces;
  172. }
  173. // CKEditor requires an element to already have the contentEditable
  174. // attribute set to "true", otherwise it won't attach an inline editor.
  175. element.setAttribute('contentEditable', 'true');
  176. return !!CKEDITOR.inline(element, settings);
  177. },
  178. /**
  179. * Loads the required external plugins for the editor.
  180. *
  181. * @param {object} format
  182. * The text format used in the editor.
  183. */
  184. _loadExternalPlugins(format) {
  185. const externalPlugins = format.editorSettings.drupalExternalPlugins;
  186. // Register and load additional CKEditor plugins as necessary.
  187. if (externalPlugins) {
  188. Object.keys(externalPlugins || {}).forEach(pluginName => {
  189. CKEDITOR.plugins.addExternal(
  190. pluginName,
  191. externalPlugins[pluginName],
  192. '',
  193. );
  194. });
  195. delete format.editorSettings.drupalExternalPlugins;
  196. }
  197. },
  198. };
  199. Drupal.ckeditor = {
  200. /**
  201. * Variable storing the current dialog's save callback.
  202. *
  203. * @type {?function}
  204. */
  205. saveCallback: null,
  206. /**
  207. * Open a dialog for a Drupal-based plugin.
  208. *
  209. * This dynamically loads jQuery UI (if necessary) using the Drupal AJAX
  210. * framework, then opens a dialog at the specified Drupal path.
  211. *
  212. * @param {CKEditor} editor
  213. * The CKEditor instance that is opening the dialog.
  214. * @param {string} url
  215. * The URL that contains the contents of the dialog.
  216. * @param {object} existingValues
  217. * Existing values that will be sent via POST to the url for the dialog
  218. * contents.
  219. * @param {function} saveCallback
  220. * A function to be called upon saving the dialog.
  221. * @param {object} dialogSettings
  222. * An object containing settings to be passed to the jQuery UI.
  223. */
  224. openDialog(editor, url, existingValues, saveCallback, dialogSettings) {
  225. // Locate a suitable place to display our loading indicator.
  226. let $target = $(editor.container.$);
  227. if (editor.elementMode === CKEDITOR.ELEMENT_MODE_REPLACE) {
  228. $target = $target.find('.cke_contents');
  229. }
  230. // Remove any previous loading indicator.
  231. $target
  232. .css('position', 'relative')
  233. .find('.ckeditor-dialog-loading')
  234. .remove();
  235. // Add a consistent dialog class.
  236. const classes = dialogSettings.dialogClass
  237. ? dialogSettings.dialogClass.split(' ')
  238. : [];
  239. classes.push('ui-dialog--narrow');
  240. dialogSettings.dialogClass = classes.join(' ');
  241. dialogSettings.autoResize = window.matchMedia(
  242. '(min-width: 600px)',
  243. ).matches;
  244. dialogSettings.width = 'auto';
  245. // Add a "Loading…" message, hide it underneath the CKEditor toolbar,
  246. // create a Drupal.Ajax instance to load the dialog and trigger it.
  247. const $content = $(
  248. `<div class="ckeditor-dialog-loading"><span style="top: -40px;" class="ckeditor-dialog-loading-link">${Drupal.t(
  249. 'Loading...',
  250. )}</span></div>`,
  251. );
  252. $content.appendTo($target);
  253. const ckeditorAjaxDialog = Drupal.ajax({
  254. dialog: dialogSettings,
  255. dialogType: 'modal',
  256. selector: '.ckeditor-dialog-loading-link',
  257. url,
  258. progress: { type: 'throbber' },
  259. submit: {
  260. editor_object: existingValues,
  261. },
  262. });
  263. ckeditorAjaxDialog.execute();
  264. // After a short delay, show "Loading…" message.
  265. window.setTimeout(() => {
  266. $content.find('span').animate({ top: '0px' });
  267. }, 1000);
  268. // Store the save callback to be executed when this dialog is closed.
  269. Drupal.ckeditor.saveCallback = saveCallback;
  270. },
  271. };
  272. // Moves the dialog to the top of the CKEDITOR stack.
  273. $(window).on('dialogcreate', (e, dialog, $element, settings) => {
  274. $('.ui-dialog--narrow').css('zIndex', CKEDITOR.config.baseFloatZIndex + 1);
  275. });
  276. // Respond to new dialogs that are opened by CKEditor, closing the AJAX loader.
  277. $(window).on('dialog:beforecreate', (e, dialog, $element, settings) => {
  278. $('.ckeditor-dialog-loading').animate({ top: '-40px' }, function() {
  279. $(this).remove();
  280. });
  281. });
  282. // Respond to dialogs that are saved, sending data back to CKEditor.
  283. $(window).on('editor:dialogsave', (e, values) => {
  284. if (Drupal.ckeditor.saveCallback) {
  285. Drupal.ckeditor.saveCallback(values);
  286. }
  287. });
  288. // Respond to dialogs that are closed, removing the current save handler.
  289. $(window).on('dialog:afterclose', (e, dialog, $element) => {
  290. if (Drupal.ckeditor.saveCallback) {
  291. Drupal.ckeditor.saveCallback = null;
  292. }
  293. });
  294. // Formulate a default formula for the maximum autoGrow height.
  295. $(document).on('drupalViewportOffsetChange', () => {
  296. CKEDITOR.config.autoGrow_maxHeight =
  297. 0.7 *
  298. (window.innerHeight - displace.offsets.top - displace.offsets.bottom);
  299. });
  300. // Redirect on hash change when the original hash has an associated CKEditor.
  301. function redirectTextareaFragmentToCKEditorInstance() {
  302. const hash = window.location.hash.substr(1);
  303. const element = document.getElementById(hash);
  304. if (element) {
  305. const editor = CKEDITOR.dom.element.get(element).getEditor();
  306. if (editor) {
  307. const id = editor.container.getAttribute('id');
  308. window.location.replace(`#${id}`);
  309. }
  310. }
  311. }
  312. $(window).on(
  313. 'hashchange.ckeditor',
  314. redirectTextareaFragmentToCKEditorInstance,
  315. );
  316. // Set autoGrow to make the editor grow the moment it is created.
  317. CKEDITOR.config.autoGrow_onStartup = true;
  318. // Default max height. Will be updated as the viewport changes.
  319. CKEDITOR.config.autoGrow_maxHeight = 0.7 * window.innerHeight;
  320. // Set the CKEditor cache-busting string to the same value as Drupal.
  321. CKEDITOR.timestamp = drupalSettings.ckeditor.timestamp;
  322. if (AjaxCommands) {
  323. /**
  324. * Command to add style sheets to a CKEditor instance.
  325. *
  326. * Works for both iframe and inline CKEditor instances.
  327. *
  328. * @param {Drupal.Ajax} [ajax]
  329. * {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
  330. * @param {object} response
  331. * The response from the Ajax request.
  332. * @param {string} response.editor_id
  333. * The CKEditor instance ID.
  334. * @param {number} [status]
  335. * The XMLHttpRequest status.
  336. *
  337. * @see http://docs.ckeditor.com/#!/api/CKEDITOR.dom.document
  338. */
  339. AjaxCommands.prototype.ckeditor_add_stylesheet = function(
  340. ajax,
  341. response,
  342. status,
  343. ) {
  344. const editor = CKEDITOR.instances[response.editor_id];
  345. if (editor) {
  346. response.stylesheets.forEach(url => {
  347. editor.document.appendStyleSheet(url);
  348. });
  349. }
  350. };
  351. }
  352. })(
  353. Drupal,
  354. Drupal.debounce,
  355. CKEDITOR,
  356. jQuery,
  357. Drupal.displace,
  358. Drupal.AjaxCommands,
  359. );